Merge progression-import-export: progression import/export + pack system rewrite
Two unrelated feature sets landed on the same dev branch this session: 1. Progression import/export (7 prior commits): owned cards + items + decks + tolerant numeric my_rotation_id parsing + literal-client-JSON wire-shape coverage. 2. Pack system rewrite (7 new commits): full-fidelity per-pack draw tables seeded from the 279 archived drawrates CSVs, replacing the pack->CardSet pool assumption. New EF entities, importer, sampler, IsEnabled admin gate on PackConfig, statistical sampler test, PackRateConfig marked Obsolete. Tests: 648/648 green. Bootstrap end-to-end: 279 PackDrawConfigs / 1973 SlotRates / 90800 CardWeights / 35 enabled + 244 disabled stubs in Packs.
This commit is contained in:
817202
SVSim.Bootstrap/Data/seeds/pack-draw-card-weights.json
Normal file
817202
SVSim.Bootstrap/Data/seeds/pack-draw-card-weights.json
Normal file
File diff suppressed because it is too large
Load Diff
1955
SVSim.Bootstrap/Data/seeds/pack-draw-config.json
Normal file
1955
SVSim.Bootstrap/Data/seeds/pack-draw-config.json
Normal file
File diff suppressed because it is too large
Load Diff
11840
SVSim.Bootstrap/Data/seeds/pack-draw-slot-rates.json
Normal file
11840
SVSim.Bootstrap/Data/seeds/pack-draw-slot-rates.json
Normal file
File diff suppressed because it is too large
Load Diff
9740
SVSim.Bootstrap/Data/seeds/pack-stubs.json
Normal file
9740
SVSim.Bootstrap/Data/seeds/pack-stubs.json
Normal file
File diff suppressed because it is too large
Load Diff
54
SVSim.Bootstrap/Data/test-fixtures/seeds/pack-stubs.json
Normal file
54
SVSim.Bootstrap/Data/test-fixtures/seeds/pack-stubs.json
Normal file
@@ -0,0 +1,54 @@
|
||||
[
|
||||
{
|
||||
"parent_gacha_id": 10001,
|
||||
"base_pack_id": 10001,
|
||||
"gacha_type": 1,
|
||||
"pack_category": 0,
|
||||
"poster_type": 0,
|
||||
"commence_date": "2016-06-17 00:00:00",
|
||||
"complete_date": "2026-06-30 23:59:59",
|
||||
"sleeve_id": 0,
|
||||
"special_sleeve_id": 0,
|
||||
"override_draw_effect_pack_id": 0,
|
||||
"override_ui_effect_pack_id": 0,
|
||||
"gacha_detail": "STUB CLC",
|
||||
"is_hide": false,
|
||||
"is_new": false,
|
||||
"is_pre_release": false,
|
||||
"open_count_limit": 0,
|
||||
"sales_period_time": null,
|
||||
"gacha_point": null,
|
||||
"child_gachas": [
|
||||
{ "gacha_id": 100011, "type_detail": 2, "cost": 200, "card_count": 1 },
|
||||
{ "gacha_id": 100012, "type_detail": 2, "cost": 1800, "card_count": 10 }
|
||||
],
|
||||
"banners": [],
|
||||
"is_enabled": false
|
||||
},
|
||||
{
|
||||
"parent_gacha_id": 95001,
|
||||
"base_pack_id": 95001,
|
||||
"gacha_type": 1,
|
||||
"pack_category": 2,
|
||||
"poster_type": 0,
|
||||
"commence_date": "2016-06-17 00:00:00",
|
||||
"complete_date": "2026-06-30 23:59:59",
|
||||
"sleeve_id": 0,
|
||||
"special_sleeve_id": 0,
|
||||
"override_draw_effect_pack_id": 0,
|
||||
"override_ui_effect_pack_id": 0,
|
||||
"gacha_detail": "7th Anniv stub",
|
||||
"is_hide": false,
|
||||
"is_new": false,
|
||||
"is_pre_release": false,
|
||||
"open_count_limit": 0,
|
||||
"sales_period_time": null,
|
||||
"gacha_point": null,
|
||||
"child_gachas": [
|
||||
{ "gacha_id": 950011, "type_detail": 2, "cost": 200, "card_count": 1 },
|
||||
{ "gacha_id": 950012, "type_detail": 2, "cost": 1800, "card_count": 10 }
|
||||
],
|
||||
"banners": [],
|
||||
"is_enabled": false
|
||||
}
|
||||
]
|
||||
102
SVSim.Bootstrap/Importers/PackDrawTableImporter.cs
Normal file
102
SVSim.Bootstrap/Importers/PackDrawTableImporter.cs
Normal file
@@ -0,0 +1,102 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Bootstrap.Models.Seed;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Bootstrap.Importers;
|
||||
|
||||
/// <summary>
|
||||
/// Idempotent upsert of the per-pack draw table from
|
||||
/// <c>seeds/pack-draw-config.json</c>, <c>pack-draw-slot-rates.json</c>, and
|
||||
/// <c>pack-draw-card-weights.json</c>. Replaces wholesale per pack (config keyed on
|
||||
/// pack_id; slot rates / card weights wiped and reinserted) — the upstream data is
|
||||
/// post-shutdown closed, so we do not preserve hand-edits on these tables.
|
||||
/// </summary>
|
||||
public class PackDrawTableImporter
|
||||
{
|
||||
public async Task<int> ImportAsync(SVSimDbContext context, string seedDir)
|
||||
{
|
||||
var configs = SeedLoader.LoadList<PackDrawConfigSeed>(Path.Combine(seedDir, "pack-draw-config.json"));
|
||||
var slotRates = SeedLoader.LoadList<PackDrawSlotRateSeed>(Path.Combine(seedDir, "pack-draw-slot-rates.json"));
|
||||
var cardWeights = SeedLoader.LoadList<PackDrawCardWeightSeed>(Path.Combine(seedDir, "pack-draw-card-weights.json"));
|
||||
|
||||
if (configs.Count == 0)
|
||||
{
|
||||
Console.WriteLine("[PackDrawTableImporter] No seed rows; skipping.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
var seedPackIds = configs.Select(c => c.PackId).ToHashSet();
|
||||
|
||||
// Full-replace strategy: wipe rows for any pack in the seed, then reinsert.
|
||||
await context.PackDrawCardWeights
|
||||
.Where(w => seedPackIds.Contains(w.PackId))
|
||||
.ExecuteDeleteAsync();
|
||||
await context.PackDrawSlotRates
|
||||
.Where(s => seedPackIds.Contains(s.PackId))
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
var existingConfigs = await context.PackDrawConfigs
|
||||
.Where(c => seedPackIds.Contains(c.Id))
|
||||
.ToDictionaryAsync(c => c.Id);
|
||||
|
||||
foreach (var s in configs)
|
||||
{
|
||||
var row = existingConfigs.TryGetValue(s.PackId, out var ex)
|
||||
? ex : new PackDrawConfigEntry { Id = s.PackId };
|
||||
row.AnimationRatePct = s.AnimationRatePct;
|
||||
row.HasBonusSlot = s.HasBonusSlot;
|
||||
row.SpecialKind = s.SpecialKind;
|
||||
row.ShortCode = s.ShortCode;
|
||||
if (ex is null) context.PackDrawConfigs.Add(row);
|
||||
}
|
||||
|
||||
foreach (var s in slotRates)
|
||||
{
|
||||
context.PackDrawSlotRates.Add(new PackDrawSlotRateEntry
|
||||
{
|
||||
PackId = s.PackId,
|
||||
Slot = ParseSlot(s.Slot),
|
||||
Tier = ParseTier(s.Tier),
|
||||
RatePct = s.RatePct,
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var s in cardWeights)
|
||||
{
|
||||
context.PackDrawCardWeights.Add(new PackDrawCardWeightEntry
|
||||
{
|
||||
PackId = s.PackId,
|
||||
Slot = ParseSlot(s.Slot),
|
||||
Tier = ParseTier(s.Tier),
|
||||
CardId = s.CardId,
|
||||
RatePct = s.RatePct,
|
||||
IsLeader = s.IsLeader,
|
||||
IsAltArt = s.IsAltArt,
|
||||
});
|
||||
}
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
Console.WriteLine($"[PackDrawTableImporter] {configs.Count} configs / {slotRates.Count} slot rates / {cardWeights.Count} card weights");
|
||||
return configs.Count;
|
||||
}
|
||||
|
||||
private static DrawSlot ParseSlot(string s) => s switch
|
||||
{
|
||||
"general" => DrawSlot.General,
|
||||
"eighth" => DrawSlot.Eighth,
|
||||
"bonus" => DrawSlot.Bonus,
|
||||
_ => throw new InvalidDataException($"PackDrawTableImporter: unknown slot \"{s}\""),
|
||||
};
|
||||
|
||||
private static DrawTier ParseTier(string s) => s switch
|
||||
{
|
||||
"bronze" => DrawTier.Bronze,
|
||||
"silver" => DrawTier.Silver,
|
||||
"gold" => DrawTier.Gold,
|
||||
"legendary" => DrawTier.Legendary,
|
||||
"special" => DrawTier.Special,
|
||||
_ => throw new InvalidDataException($"PackDrawTableImporter: unknown tier \"{s}\""),
|
||||
};
|
||||
}
|
||||
@@ -61,6 +61,7 @@ public class PackImporter
|
||||
ExchangeablePoint = s.GachaPoint.ExchangeablePoint,
|
||||
IncreaseGachaPoint = s.GachaPoint.IncreaseGachaPoint,
|
||||
};
|
||||
pack.IsEnabled = s.IsEnabled;
|
||||
|
||||
// Owned collections -- clear and rehydrate (matches the previous wholesale-replace semantics).
|
||||
pack.ChildGachas.Clear();
|
||||
@@ -101,7 +102,75 @@ public class PackImporter
|
||||
}
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
Console.WriteLine($"[PackImporter] +{created}/~{updated}");
|
||||
return created + updated;
|
||||
Console.WriteLine($"[PackImporter] capture: +{created}/~{updated}");
|
||||
|
||||
// Second pass: synthesized stubs from pack-stubs.json. Skip any pack_id that already
|
||||
// exists from the live-capture pass (capture wins on conflict).
|
||||
var stubs = SeedLoader.LoadList<PackSeed>(Path.Combine(seedDir, "pack-stubs.json"));
|
||||
int stubsAdded = 0;
|
||||
foreach (var s in stubs)
|
||||
{
|
||||
if (s.ParentGachaId == 0) continue;
|
||||
if (existing.ContainsKey(s.ParentGachaId)) continue;
|
||||
|
||||
var pack = new PackConfigEntry
|
||||
{
|
||||
Id = s.ParentGachaId,
|
||||
BasePackId = s.BasePackId,
|
||||
GachaType = s.GachaType,
|
||||
PackCategory = (PackCategory)s.PackCategory,
|
||||
PosterType = s.PosterType,
|
||||
CommenceDate = ParseWireDateTime(s.CommenceDate),
|
||||
CompleteDate = ParseWireDateTime(s.CompleteDate),
|
||||
SleeveId = s.SleeveId,
|
||||
SpecialSleeveId = s.SpecialSleeveId,
|
||||
OverrideDrawEffectPackId = s.OverrideDrawEffectPackId,
|
||||
OverrideUiEffectPackId = s.OverrideUiEffectPackId,
|
||||
GachaDetail = s.GachaDetail,
|
||||
IsHide = s.IsHide,
|
||||
IsNew = s.IsNew,
|
||||
IsPreRelease = s.IsPreRelease,
|
||||
OpenCountLimit = s.OpenCountLimit,
|
||||
SalesPeriodTime = string.IsNullOrEmpty(s.SalesPeriodTime) ? null : ParseWireDateTime(s.SalesPeriodTime),
|
||||
GachaPointConfig = s.GachaPoint is null ? null : new PackGachaPointConfig
|
||||
{
|
||||
ExchangeablePoint = s.GachaPoint.ExchangeablePoint,
|
||||
IncreaseGachaPoint = s.GachaPoint.IncreaseGachaPoint,
|
||||
},
|
||||
IsEnabled = s.IsEnabled,
|
||||
};
|
||||
foreach (var c in s.ChildGachas)
|
||||
{
|
||||
pack.ChildGachas.Add(new PackChildGachaEntry
|
||||
{
|
||||
GachaId = c.GachaId,
|
||||
TypeDetail = c.TypeDetail,
|
||||
Cost = c.Cost,
|
||||
CardCount = c.CardCount,
|
||||
ItemId = c.ItemId,
|
||||
IsDailySingle = c.IsDailySingle,
|
||||
OverrideIncreaseGachaPoint = c.OverrideIncreaseGachaPoint,
|
||||
PurchaseLimitCount = c.PurchaseLimitCount,
|
||||
FreeGachaCampaignId = c.FreeGachaCampaignId,
|
||||
CampaignName = c.CampaignName,
|
||||
});
|
||||
}
|
||||
foreach (var b in s.Banners)
|
||||
{
|
||||
pack.Banners.Add(new PackBannerEntry
|
||||
{
|
||||
BannerName = b.BannerName,
|
||||
DialogTitle = b.DialogTitle,
|
||||
});
|
||||
}
|
||||
context.Packs.Add(pack);
|
||||
existing[s.ParentGachaId] = pack;
|
||||
stubsAdded++;
|
||||
}
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
Console.WriteLine($"[PackImporter] stubs: +{stubsAdded}");
|
||||
|
||||
return created + updated + stubsAdded;
|
||||
}
|
||||
}
|
||||
|
||||
14
SVSim.Bootstrap/Models/Seed/PackDrawCardWeightSeed.cs
Normal file
14
SVSim.Bootstrap/Models/Seed/PackDrawCardWeightSeed.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.Bootstrap.Models.Seed;
|
||||
|
||||
public sealed class PackDrawCardWeightSeed
|
||||
{
|
||||
[JsonPropertyName("pack_id")] public int PackId { get; set; }
|
||||
[JsonPropertyName("slot")] public string Slot { get; set; } = "general";
|
||||
[JsonPropertyName("tier")] public string Tier { get; set; } = "bronze";
|
||||
[JsonPropertyName("card_id")] public long CardId { get; set; }
|
||||
[JsonPropertyName("rate_pct")] public double? RatePct { get; set; }
|
||||
[JsonPropertyName("is_leader")] public bool IsLeader { get; set; }
|
||||
[JsonPropertyName("is_alt_art")] public bool IsAltArt { get; set; }
|
||||
}
|
||||
12
SVSim.Bootstrap/Models/Seed/PackDrawConfigSeed.cs
Normal file
12
SVSim.Bootstrap/Models/Seed/PackDrawConfigSeed.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.Bootstrap.Models.Seed;
|
||||
|
||||
public sealed class PackDrawConfigSeed
|
||||
{
|
||||
[JsonPropertyName("pack_id")] public int PackId { get; set; }
|
||||
[JsonPropertyName("short_code")] public string? ShortCode { get; set; }
|
||||
[JsonPropertyName("animation_rate_pct")] public double AnimationRatePct { get; set; }
|
||||
[JsonPropertyName("has_bonus_slot")] public bool HasBonusSlot { get; set; }
|
||||
[JsonPropertyName("special_kind")] public string? SpecialKind { get; set; }
|
||||
}
|
||||
11
SVSim.Bootstrap/Models/Seed/PackDrawSlotRateSeed.cs
Normal file
11
SVSim.Bootstrap/Models/Seed/PackDrawSlotRateSeed.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.Bootstrap.Models.Seed;
|
||||
|
||||
public sealed class PackDrawSlotRateSeed
|
||||
{
|
||||
[JsonPropertyName("pack_id")] public int PackId { get; set; }
|
||||
[JsonPropertyName("slot")] public string Slot { get; set; } = "general";
|
||||
[JsonPropertyName("tier")] public string Tier { get; set; } = "bronze";
|
||||
[JsonPropertyName("rate_pct")] public double RatePct { get; set; }
|
||||
}
|
||||
@@ -24,6 +24,7 @@ public sealed class PackSeed
|
||||
[JsonPropertyName("gacha_point")] public PackGachaPointSeed? GachaPoint { get; set; }
|
||||
[JsonPropertyName("child_gachas")] public List<PackChildGachaSeed> ChildGachas { get; set; } = new();
|
||||
[JsonPropertyName("banners")] public List<PackBannerSeed> Banners { get; set; } = new();
|
||||
[JsonPropertyName("is_enabled")] public bool IsEnabled { get; set; } = true;
|
||||
}
|
||||
|
||||
public sealed class PackGachaPointSeed
|
||||
|
||||
@@ -116,6 +116,7 @@ public static class Program
|
||||
|
||||
await new DefaultDeckImporter().ImportAsync(context, opts.SeedDir);
|
||||
await new PackImporter().ImportAsync(context, opts.SeedDir);
|
||||
await new PackDrawTableImporter().ImportAsync(context, opts.SeedDir);
|
||||
|
||||
// BuildDeck pipeline: series CSV → catalog JSON → package CSV. Catalog must run after
|
||||
// series CSV (FK on products → series) and before package CSV (so the catalog-side
|
||||
|
||||
8
SVSim.Database/Enums/DrawSlot.cs
Normal file
8
SVSim.Database/Enums/DrawSlot.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace SVSim.Database.Enums;
|
||||
|
||||
public enum DrawSlot
|
||||
{
|
||||
General = 0,
|
||||
Eighth = 1,
|
||||
Bonus = 2,
|
||||
}
|
||||
16
SVSim.Database/Enums/DrawTier.cs
Normal file
16
SVSim.Database/Enums/DrawTier.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace SVSim.Database.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Per-draw page tier the slot rolls into. Distinct from card-master <see cref="Rarity"/>:
|
||||
/// for the four base values they line up, but <c>Special</c> covers the per-pack
|
||||
/// "Leader Card" / "Limited-Time Leader" tiers — its cards are typically Rarity.Legendary
|
||||
/// with the IsLeader printing flag set.
|
||||
/// </summary>
|
||||
public enum DrawTier
|
||||
{
|
||||
Bronze = 0,
|
||||
Silver = 1,
|
||||
Gold = 2,
|
||||
Legendary = 3,
|
||||
Special = 4,
|
||||
}
|
||||
3930
SVSim.Database/Migrations/20260531013928_AddPackDrawTable.Designer.cs
generated
Normal file
3930
SVSim.Database/Migrations/20260531013928_AddPackDrawTable.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
107
SVSim.Database/Migrations/20260531013928_AddPackDrawTable.cs
Normal file
107
SVSim.Database/Migrations/20260531013928_AddPackDrawTable.cs
Normal file
@@ -0,0 +1,107 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SVSim.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPackDrawTable : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsEnabled",
|
||||
table: "Packs",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PackDrawCardWeights",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
PackId = table.Column<int>(type: "integer", nullable: false),
|
||||
Slot = table.Column<int>(type: "integer", nullable: false),
|
||||
Tier = table.Column<int>(type: "integer", nullable: false),
|
||||
CardId = table.Column<long>(type: "bigint", nullable: false),
|
||||
RatePct = table.Column<double>(type: "double precision", nullable: true),
|
||||
IsLeader = table.Column<bool>(type: "boolean", nullable: false),
|
||||
IsAltArt = table.Column<bool>(type: "boolean", nullable: false),
|
||||
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PackDrawCardWeights", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PackDrawConfigs",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false),
|
||||
AnimationRatePct = table.Column<double>(type: "double precision", nullable: false),
|
||||
HasBonusSlot = table.Column<bool>(type: "boolean", nullable: false),
|
||||
SpecialKind = table.Column<string>(type: "text", nullable: true),
|
||||
ShortCode = table.Column<string>(type: "text", nullable: true),
|
||||
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PackDrawConfigs", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PackDrawSlotRates",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
PackId = table.Column<int>(type: "integer", nullable: false),
|
||||
Slot = table.Column<int>(type: "integer", nullable: false),
|
||||
Tier = table.Column<int>(type: "integer", nullable: false),
|
||||
RatePct = table.Column<double>(type: "double precision", nullable: false),
|
||||
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PackDrawSlotRates", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PackDrawCardWeights_PackId_Slot_Tier",
|
||||
table: "PackDrawCardWeights",
|
||||
columns: new[] { "PackId", "Slot", "Tier" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PackDrawSlotRates_PackId_Slot_Tier",
|
||||
table: "PackDrawSlotRates",
|
||||
columns: new[] { "PackId", "Slot", "Tier" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "PackDrawCardWeights");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "PackDrawConfigs");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "PackDrawSlotRates");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsEnabled",
|
||||
table: "Packs");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1500,6 +1500,9 @@ namespace SVSim.Database.Migrations
|
||||
b.Property<int>("GachaType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsHide")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
@@ -1538,6 +1541,110 @@ namespace SVSim.Database.Migrations
|
||||
b.ToTable("Packs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.PackDrawCardWeightEntry", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<long>("CardId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<DateTime>("DateCreated")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DateUpdated")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("IsAltArt")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsLeader")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("PackId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<double?>("RatePct")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<int>("Slot")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Tier")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PackId", "Slot", "Tier");
|
||||
|
||||
b.ToTable("PackDrawCardWeights");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.PackDrawConfigEntry", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<double>("AnimationRatePct")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<DateTime>("DateCreated")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DateUpdated")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("HasBonusSlot")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("ShortCode")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SpecialKind")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("PackDrawConfigs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.PackDrawSlotRateEntry", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("DateCreated")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DateUpdated")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("PackId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<double>("RatePct")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<int>("Slot")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Tier")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PackId", "Slot", "Tier")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PackDrawSlotRates");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.PaymentItemEntry", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace SVSim.Database.Models.Config;
|
||||
/// <see cref="ShippedDefaults"/>, not in the initialiser — see PerSlot docstring.
|
||||
/// </summary>
|
||||
[ConfigSection("PackRates")]
|
||||
[Obsolete("PackRateConfig is no longer consulted by PackOpenService — per-pack rates come from PackDrawTable. Retire once v1 stabilizes.")]
|
||||
public class PackRateConfig
|
||||
{
|
||||
/// <summary>
|
||||
|
||||
@@ -33,6 +33,13 @@ public class PackConfigEntry : BaseEntity<int>
|
||||
|
||||
public int OpenCountLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Server admin gate. True for live-capture-derived rows; false for synthesized stubs
|
||||
/// (operator opt-in per pack). Filtered in PackRepository.GetActivePacks; distinct from
|
||||
/// the wire-mirror IsHide.
|
||||
/// </summary>
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
|
||||
public PackGachaPointConfig? GachaPointConfig { get; set; }
|
||||
|
||||
public List<PackBannerEntry> Banners { get; set; } = new();
|
||||
|
||||
24
SVSim.Database/Models/PackDrawCardWeightEntry.cs
Normal file
24
SVSim.Database/Models/PackDrawCardWeightEntry.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using SVSim.Database.Common;
|
||||
using SVSim.Database.Enums;
|
||||
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Per-card-rate fact: which card prints in which (pack, slot, tier) at what rate.
|
||||
/// RatePct is nullable for rate-less "Guaranteed Leader Card" rows (sampler uses
|
||||
/// "uniform over (pool minus owned)" in that case).
|
||||
/// </summary>
|
||||
public class PackDrawCardWeightEntry : BaseEntity<long>
|
||||
{
|
||||
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||
public override long Id { get; set; }
|
||||
|
||||
public int PackId { get; set; }
|
||||
public DrawSlot Slot { get; set; }
|
||||
public DrawTier Tier { get; set; }
|
||||
public long CardId { get; set; }
|
||||
public double? RatePct { get; set; }
|
||||
public bool IsLeader { get; set; }
|
||||
public bool IsAltArt { get; set; }
|
||||
}
|
||||
16
SVSim.Database/Models/PackDrawConfigEntry.cs
Normal file
16
SVSim.Database/Models/PackDrawConfigEntry.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using SVSim.Database.Common;
|
||||
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
/// <summary>
|
||||
/// One row per pack covered by drawrates data. PK is the pack id (matches PackConfigEntry.Id
|
||||
/// for live-capture rows; standalone for archive-only packs). Weak relationship — PackDraw rows
|
||||
/// exist for all archived packs even when no PackConfigEntry is enabled.
|
||||
/// </summary>
|
||||
public class PackDrawConfigEntry : BaseEntity<int>
|
||||
{
|
||||
public double AnimationRatePct { get; set; }
|
||||
public bool HasBonusSlot { get; set; }
|
||||
public string? SpecialKind { get; set; }
|
||||
public string? ShortCode { get; set; }
|
||||
}
|
||||
20
SVSim.Database/Models/PackDrawSlotRateEntry.cs
Normal file
20
SVSim.Database/Models/PackDrawSlotRateEntry.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using SVSim.Database.Common;
|
||||
using SVSim.Database.Enums;
|
||||
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Per (pack, slot, tier) rate. Natural key (PackId, Slot, Tier) is enforced via unique index.
|
||||
/// Id is auto-generated — override BaseEntity's [DatabaseGenerated(None)] default.
|
||||
/// </summary>
|
||||
public class PackDrawSlotRateEntry : BaseEntity<long>
|
||||
{
|
||||
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||
public override long Id { get; set; }
|
||||
|
||||
public int PackId { get; set; }
|
||||
public DrawSlot Slot { get; set; }
|
||||
public DrawTier Tier { get; set; }
|
||||
public double RatePct { get; set; }
|
||||
}
|
||||
@@ -12,7 +12,7 @@ public class PackRepository : IPackRepository
|
||||
await _db.Packs
|
||||
.Include(p => p.ChildGachas)
|
||||
.Include(p => p.Banners)
|
||||
.Where(p => p.CommenceDate <= now && p.CompleteDate >= now)
|
||||
.Where(p => p.IsEnabled && p.CommenceDate <= now && p.CompleteDate >= now)
|
||||
// parent_gacha_id DESC matches the prod /pack/info wire order. The tutorial pack
|
||||
// UI runs with controls locked and auto-selects the FIRST entry in
|
||||
// pack_config_list, so the legendary starter pack (99047) MUST be index 0 for the
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace SVSim.Database.Repositories.PackDrawTables;
|
||||
|
||||
public interface IPackDrawTableRepository
|
||||
{
|
||||
/// <summary>Returns the draw table for <paramref name="packId"/>, or null if not seeded.</summary>
|
||||
Task<PackDrawTable?> GetAsync(int packId);
|
||||
}
|
||||
14
SVSim.Database/Repositories/PackDrawTable/PackDrawTable.cs
Normal file
14
SVSim.Database/Repositories/PackDrawTable/PackDrawTable.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Database.Repositories.PackDrawTables;
|
||||
|
||||
/// <summary>
|
||||
/// All draw data for a single pack: per-pack config + slot rates + per-card weights.
|
||||
/// Loaded as one unit by <see cref="IPackDrawTableRepository.GetAsync"/>.
|
||||
/// </summary>
|
||||
public sealed class PackDrawTable
|
||||
{
|
||||
public required PackDrawConfigEntry Config { get; init; }
|
||||
public required IReadOnlyList<PackDrawSlotRateEntry> SlotRates { get; init; }
|
||||
public required IReadOnlyList<PackDrawCardWeightEntry> CardWeights { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace SVSim.Database.Repositories.PackDrawTables;
|
||||
|
||||
public class PackDrawTableRepository : IPackDrawTableRepository
|
||||
{
|
||||
private readonly SVSimDbContext _db;
|
||||
public PackDrawTableRepository(SVSimDbContext db) { _db = db; }
|
||||
|
||||
public async Task<PackDrawTable?> GetAsync(int packId)
|
||||
{
|
||||
var config = await _db.PackDrawConfigs.FirstOrDefaultAsync(c => c.Id == packId);
|
||||
if (config is null) return null;
|
||||
|
||||
var slotRates = await _db.PackDrawSlotRates
|
||||
.Where(s => s.PackId == packId)
|
||||
.ToListAsync();
|
||||
|
||||
var cardWeights = await _db.PackDrawCardWeights
|
||||
.Where(w => w.PackId == packId)
|
||||
.ToListAsync();
|
||||
|
||||
return new PackDrawTable
|
||||
{
|
||||
Config = config,
|
||||
SlotRates = slotRates,
|
||||
CardWeights = cardWeights,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,9 @@ public class SVSimDbContext : DbContext
|
||||
public DbSet<SpecialDeckFormatEntry> SpecialDeckFormats => Set<SpecialDeckFormatEntry>();
|
||||
public DbSet<PaymentItemEntry> PaymentItems => Set<PaymentItemEntry>();
|
||||
public DbSet<PackConfigEntry> Packs => Set<PackConfigEntry>();
|
||||
public DbSet<PackDrawConfigEntry> PackDrawConfigs => Set<PackDrawConfigEntry>();
|
||||
public DbSet<PackDrawSlotRateEntry> PackDrawSlotRates => Set<PackDrawSlotRateEntry>();
|
||||
public DbSet<PackDrawCardWeightEntry> PackDrawCardWeights => Set<PackDrawCardWeightEntry>();
|
||||
public DbSet<BuildDeckSeriesEntry> BuildDeckSeries => Set<BuildDeckSeriesEntry>();
|
||||
public DbSet<BuildDeckProductEntry> BuildDeckProducts => Set<BuildDeckProductEntry>();
|
||||
public DbSet<StoryDeckEntry> StoryDecks => Set<StoryDeckEntry>();
|
||||
@@ -146,6 +149,15 @@ public class SVSimDbContext : DbContext
|
||||
|
||||
modelBuilder.Entity<PackConfigEntry>().OwnsMany(p => p.ChildGachas);
|
||||
modelBuilder.Entity<PackConfigEntry>().OwnsMany(p => p.Banners);
|
||||
|
||||
modelBuilder.Entity<PackDrawSlotRateEntry>(e =>
|
||||
{
|
||||
e.HasIndex(x => new { x.PackId, x.Slot, x.Tier }).IsUnique();
|
||||
});
|
||||
modelBuilder.Entity<PackDrawCardWeightEntry>(e =>
|
||||
{
|
||||
e.HasIndex(x => new { x.PackId, x.Slot, x.Tier });
|
||||
});
|
||||
modelBuilder.Entity<Viewer>().OwnsMany(v => v.PackOpenCounts);
|
||||
|
||||
// OwnedCardEntry and OwnedItemEntry use composite PK (ViewerId, Id) where Id is auto-
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Repositories.Viewer;
|
||||
using SVSim.EmulatedEntrypoint.Extensions;
|
||||
using SVSim.EmulatedEntrypoint.Infrastructure;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Admin;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Admin;
|
||||
@@ -20,11 +21,14 @@ public class AdminController : SVSimController
|
||||
{
|
||||
private readonly IViewerRepository _viewerRepository;
|
||||
private readonly SVSimDbContext _dbContext;
|
||||
private readonly ILogger<AdminController> _logger;
|
||||
|
||||
public AdminController(IViewerRepository viewerRepository, SVSimDbContext dbContext)
|
||||
public AdminController(IViewerRepository viewerRepository, SVSimDbContext dbContext,
|
||||
ILogger<AdminController> logger)
|
||||
{
|
||||
_viewerRepository = viewerRepository;
|
||||
_dbContext = dbContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -81,6 +85,9 @@ public class AdminController : SVSimController
|
||||
.Include(v => v.Degrees)
|
||||
.Include(v => v.LeaderSkins)
|
||||
.Include(v => v.MyPageBackgrounds)
|
||||
.Include(v => v.Cards).ThenInclude(c => c.Card)
|
||||
.Include(v => v.Items).ThenInclude(i => i.Item)
|
||||
.Include(v => v.Decks).ThenInclude(d => d.Cards)
|
||||
.FirstAsync(v => v.Id == viewerId);
|
||||
|
||||
if (request.DisplayName is not null) viewer.DisplayName = request.DisplayName;
|
||||
@@ -124,25 +131,145 @@ public class AdminController : SVSimController
|
||||
}
|
||||
}
|
||||
|
||||
// Clone the 8 starter decks into the viewer when freshly created — workaround for a
|
||||
// client-side NRE in the deck-edit menu (DeckListUI.IsVisibleCreateNewButton at
|
||||
// decompile Wizard/DeckListUI.cs:316 unconditionally reads `_deckGroup.DeckFormat`, but
|
||||
// _deckGroup is null when GetCustomDeckGroup() finds no matching CustomDeck group in
|
||||
// DeckGroupDataBase — which is exactly what happens for a fresh viewer). Prod players
|
||||
// acquire decks via tutorial; we shortcut by seeding the 8 defaults at import time.
|
||||
// See docs/audits/deck-edit-empty-decklist-nre-2026-05-23.md for the full background.
|
||||
if (wasCreated)
|
||||
// Accumulates distinct card_ids referenced by the import (owned list + deck lists)
|
||||
// that aren't in our card master. Surfaced in the response and logged after save.
|
||||
var skippedCardIds = new HashSet<long>();
|
||||
|
||||
if (request.OwnedCards is not null)
|
||||
{
|
||||
await CloneDefaultDecksToViewerAsync(viewer);
|
||||
var wanted = request.OwnedCards
|
||||
.GroupBy(c => c.CardId)
|
||||
.Select(g => g.First())
|
||||
.ToList();
|
||||
var ids = wanted.Select(c => c.CardId).ToList();
|
||||
var cardMaster = await _dbContext.Cards
|
||||
.Where(c => ids.Contains(c.Id))
|
||||
.ToDictionaryAsync(c => c.Id);
|
||||
|
||||
viewer.Cards.Clear();
|
||||
foreach (var c in wanted)
|
||||
{
|
||||
if (!cardMaster.TryGetValue(c.CardId, out var card))
|
||||
{
|
||||
skippedCardIds.Add(c.CardId);
|
||||
continue;
|
||||
}
|
||||
viewer.Cards.Add(new OwnedCardEntry
|
||||
{
|
||||
Card = card,
|
||||
Count = Math.Clamp(c.Count, 1, OwnedCardEntry.MaxCopies),
|
||||
IsProtected = c.IsProtected,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (request.Items is not null)
|
||||
{
|
||||
var wanted = request.Items
|
||||
.GroupBy(i => i.ItemId)
|
||||
.Select(g => g.First())
|
||||
.ToList();
|
||||
var ids = wanted.Select(i => i.ItemId).ToList();
|
||||
var itemMaster = await _dbContext.Items
|
||||
.Where(i => ids.Contains(i.Id))
|
||||
.ToDictionaryAsync(i => i.Id);
|
||||
|
||||
viewer.Items.Clear();
|
||||
foreach (var i in wanted)
|
||||
{
|
||||
if (!itemMaster.TryGetValue(i.ItemId, out var item)) continue; // unknown master id
|
||||
viewer.Items.Add(new OwnedItemEntry { Item = item, Count = i.Count, Viewer = viewer });
|
||||
}
|
||||
}
|
||||
|
||||
if (request.Decks is not null)
|
||||
{
|
||||
var allDeckCardIds = request.Decks
|
||||
.Where(d => d.CardIdArray is not null)
|
||||
.SelectMany(d => d.CardIdArray!)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var deckCardMaster = await _dbContext.Cards
|
||||
.Where(c => allDeckCardIds.Contains(c.Id))
|
||||
.ToDictionaryAsync(c => c.Id);
|
||||
var classes = await _dbContext.Classes.Include(c => c.LeaderSkins).ToDictionaryAsync(c => c.Id);
|
||||
var sleeves = await _dbContext.Sleeves.ToDictionaryAsync(s => (long)s.Id);
|
||||
var leaderSkins = await _dbContext.LeaderSkins.ToDictionaryAsync(s => s.Id);
|
||||
var defaultSleeve = await _dbContext.Sleeves.FindAsync((int)DefaultSleeveId);
|
||||
var latestMyRotationId = (await _dbContext.MyRotationSettings.AsNoTracking()
|
||||
.Select(s => (int?)s.Id)
|
||||
.OrderByDescending(id => id)
|
||||
.FirstOrDefaultAsync())?.ToString();
|
||||
|
||||
_dbContext.RemoveRange(viewer.Decks);
|
||||
viewer.Decks.Clear();
|
||||
|
||||
foreach (var d in request.Decks)
|
||||
{
|
||||
// A /load/index dump carries every deck slot, most of them empty placeholders
|
||||
// (no cards). Skip them: the client manages empty slots itself (it's why the old
|
||||
// default-deck cloning was removed), and importing empty MyRotation slots would
|
||||
// otherwise persist decks with a bogus rotation id.
|
||||
if ((d.CardIdArray?.Count ?? 0) == 0) continue;
|
||||
|
||||
Format format;
|
||||
try { format = FormatExtensions.FromApi(d.DeckFormat); }
|
||||
catch (ArgumentOutOfRangeException) { continue; } // skip unsupported wire format
|
||||
if (!classes.TryGetValue(d.ClassId, out var classEntry)) continue;
|
||||
|
||||
SleeveEntry? sleeve = null;
|
||||
if (d.SleeveId.HasValue) sleeves.TryGetValue(d.SleeveId.Value, out sleeve);
|
||||
sleeve ??= defaultSleeve;
|
||||
|
||||
LeaderSkinEntry? leaderSkin = null;
|
||||
if (d.LeaderSkinId.HasValue) leaderSkins.TryGetValue(d.LeaderSkinId.Value, out leaderSkin);
|
||||
leaderSkin ??= classEntry.DefaultLeaderSkin ?? classEntry.LeaderSkins.FirstOrDefault();
|
||||
|
||||
if (sleeve is null || leaderSkin is null) continue;
|
||||
|
||||
var cards = (d.CardIdArray ?? new List<long>())
|
||||
.GroupBy(id => id)
|
||||
.Where(g =>
|
||||
{
|
||||
if (deckCardMaster.ContainsKey(g.Key)) return true;
|
||||
skippedCardIds.Add(g.Key);
|
||||
return false;
|
||||
})
|
||||
.Select(g => new DeckCard { Card = deckCardMaster[g.Key], Count = g.Count() })
|
||||
.ToList();
|
||||
|
||||
viewer.Decks.Add(new ShadowverseDeckEntry
|
||||
{
|
||||
Name = d.DeckName ?? $"Deck {d.DeckNo}",
|
||||
Number = d.DeckNo,
|
||||
Format = format,
|
||||
Class = classEntry,
|
||||
Sleeve = sleeve,
|
||||
LeaderSkin = leaderSkin,
|
||||
RandomLeaderSkin = (d.IsRandomLeaderSkin ?? 0) != 0,
|
||||
Cards = cards,
|
||||
MyRotationId = format == Format.MyRotation ? (d.MyRotationId ?? latestMyRotationId) : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
if (skippedCardIds.Count > 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"ImportViewer (steam_id={SteamId}, viewer_id={ViewerId}): skipped {Count} unknown " +
|
||||
"card_id(s) not present in the card master. Sample: [{Sample}]",
|
||||
request.SteamId, viewer.Id, skippedCardIds.Count,
|
||||
string.Join(", ", skippedCardIds.Take(20)));
|
||||
}
|
||||
|
||||
return new ImportViewerResponse
|
||||
{
|
||||
ViewerId = viewer.Id,
|
||||
ShortUdid = viewer.ShortUdid,
|
||||
WasCreated = wasCreated
|
||||
WasCreated = wasCreated,
|
||||
SkippedCardCount = skippedCardIds.Count,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -162,81 +289,8 @@ public class AdminController : SVSimController
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default sleeve id used for cloned starter decks. Matches prod's wire shape — every
|
||||
/// default_deck_list entry on /deck/info has <c>sleeve_id: 3000011</c>.
|
||||
/// Fallback sleeve id used when an imported deck has no resolvable <c>sleeve_id</c>.
|
||||
/// 3000011 is prod's default deck sleeve.
|
||||
/// </summary>
|
||||
private const long DefaultSleeveId = 3000011L;
|
||||
|
||||
/// <summary>
|
||||
/// Formats we clone the starter decks into. Each format the player can open the deck-edit
|
||||
/// menu for needs at least one CustomDeck group in <c>Data.DeckGroupDataBase</c>, otherwise
|
||||
/// the client NREs on <c>_deckGroup.DeckFormat</c> in DeckListUI.IsVisibleCreateNewButton.
|
||||
/// Rotation / Unlimited / MyRotation are the always-active base formats; PreRotation /
|
||||
/// Crossover / Avatar are seasonal and gated by UI state — leave them empty for now (see
|
||||
/// docs/audits/deck-edit-empty-decklist-nre-2026-05-23.md follow-ups).
|
||||
/// </summary>
|
||||
private static readonly Format[] SeededDeckFormats = { Format.Rotation, Format.Unlimited, Format.MyRotation };
|
||||
|
||||
/// <summary>
|
||||
/// Materialize the 8 default decks into the viewer's deck collection, once per seeded format.
|
||||
/// The tracked <paramref name="viewer"/> instance gets new ShadowverseDeckEntry rows added to
|
||||
/// its Decks navigation; EF picks them up on the caller's SaveChangesAsync.
|
||||
/// </summary>
|
||||
private async Task CloneDefaultDecksToViewerAsync(Viewer viewer)
|
||||
{
|
||||
var defaultDecks = await _dbContext.DefaultDecks.AsNoTracking().OrderBy(d => d.Id).ToListAsync();
|
||||
if (defaultDecks.Count == 0) return;
|
||||
|
||||
// Resolve nav-property entities once. Classes need LeaderSkins included for the
|
||||
// DefaultLeaderSkin nav lookup. Cards are fetched in one bulk query keyed by id.
|
||||
var classes = await _dbContext.Classes.Include(c => c.LeaderSkins).ToDictionaryAsync(c => c.Id);
|
||||
var defaultSleeve = await _dbContext.Sleeves.FindAsync((int)DefaultSleeveId);
|
||||
|
||||
var allCardIds = defaultDecks
|
||||
.SelectMany(d => JsonSerializer.Deserialize<List<long>>(d.CardIdArray, JsonbReadOptions.Instance) ?? new List<long>())
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var cards = await _dbContext.Cards.Where(c => allCardIds.Contains(c.Id)).ToDictionaryAsync(c => c.Id);
|
||||
|
||||
// Seeded MyRotation placeholder decks need a real rotation_id, otherwise the client's
|
||||
// DeckData.GetMyRotationClassName NREs on `info.LastPackText` when the user clicks one
|
||||
// (info is null because Data.MyRotationAllInfo.Get(null) returns null). Pick the highest
|
||||
// rotation id available — it includes the most recent pack and therefore covers every
|
||||
// class (including class_id=8 Nemesis, which requires last_pack >= 10007).
|
||||
var latestMyRotationId = (await _dbContext.MyRotationSettings.AsNoTracking()
|
||||
.Select(s => (int?)s.Id)
|
||||
.OrderByDescending(id => id)
|
||||
.FirstOrDefaultAsync())?.ToString();
|
||||
|
||||
foreach (var format in SeededDeckFormats)
|
||||
{
|
||||
int slot = 1;
|
||||
foreach (var d in defaultDecks)
|
||||
{
|
||||
if (!classes.TryGetValue(d.ClassId, out var classEntry)) continue;
|
||||
var leaderSkin = classEntry.DefaultLeaderSkin ?? classEntry.LeaderSkins.FirstOrDefault();
|
||||
if (leaderSkin is null || defaultSleeve is null) continue;
|
||||
|
||||
var cardIdArray = JsonSerializer.Deserialize<List<long>>(d.CardIdArray, JsonbReadOptions.Instance) ?? new List<long>();
|
||||
var deckCards = cardIdArray
|
||||
.GroupBy(id => id)
|
||||
.Where(g => cards.ContainsKey(g.Key))
|
||||
.Select(g => new DeckCard { Card = cards[g.Key], Count = g.Count() })
|
||||
.ToList();
|
||||
|
||||
viewer.Decks.Add(new ShadowverseDeckEntry
|
||||
{
|
||||
Name = d.DeckName,
|
||||
Number = slot++,
|
||||
Format = format,
|
||||
Class = classEntry,
|
||||
Sleeve = defaultSleeve,
|
||||
LeaderSkin = leaderSkin,
|
||||
RandomLeaderSkin = false,
|
||||
Cards = deckCards,
|
||||
MyRotationId = format == Format.MyRotation ? latestMyRotationId : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Repositories.Pack;
|
||||
using SVSim.Database.Repositories.PackDrawTables;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Pack;
|
||||
@@ -25,7 +26,8 @@ public class PackController : SVSimController
|
||||
|
||||
private readonly IPackRepository _packs;
|
||||
private readonly PackOpenService _opener;
|
||||
private readonly ICardPoolProvider _pools;
|
||||
private readonly IPackDrawTableRepository _drawTables;
|
||||
private readonly ICardFoilLookup _foils;
|
||||
private readonly IRandom _rng;
|
||||
private readonly SVSimDbContext _db;
|
||||
private readonly ICardAcquisitionService _acquisition;
|
||||
@@ -36,7 +38,8 @@ public class PackController : SVSimController
|
||||
public PackController(
|
||||
IPackRepository packs,
|
||||
PackOpenService opener,
|
||||
ICardPoolProvider pools,
|
||||
IPackDrawTableRepository drawTables,
|
||||
ICardFoilLookup foils,
|
||||
IRandom rng,
|
||||
SVSimDbContext db,
|
||||
ICardAcquisitionService acquisition,
|
||||
@@ -46,7 +49,8 @@ public class PackController : SVSimController
|
||||
{
|
||||
_packs = packs;
|
||||
_opener = opener;
|
||||
_pools = pools;
|
||||
_drawTables = drawTables;
|
||||
_foils = foils;
|
||||
_rng = rng;
|
||||
_db = db;
|
||||
_acquisition = acquisition;
|
||||
@@ -343,7 +347,28 @@ public class PackController : SVSimController
|
||||
|
||||
// Draw + persist. DAILY single overrides packNumber to 1 (it's a one-card open).
|
||||
int drawCount = child.IsDailySingle ? 1 : packNumber;
|
||||
var draw = _opener.Draw(pack, _pools, drawCount, request.ExcludeCardIds, _rng);
|
||||
|
||||
var drawTable = await _drawTables.GetAsync(pack.Id);
|
||||
if (drawTable is null)
|
||||
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "pack_draw_table_missing" });
|
||||
|
||||
// Owned card_ids for the rate-less Guaranteed-Leader-Card branch. Project to longs to
|
||||
// avoid pulling viewer.Cards entities into memory. Shadow-FK access (EF.Property) per
|
||||
// the project_ef_nav_include_pitfall memory.
|
||||
var ownedCardIds = await _db.Viewers
|
||||
.Where(v => v.Id == viewerId)
|
||||
.SelectMany(v => v.Cards)
|
||||
.Select(c => (long)EF.Property<int>(c, "CardId"))
|
||||
.ToListAsync();
|
||||
|
||||
var draw = _opener.Draw(
|
||||
drawTable,
|
||||
pack,
|
||||
drawCount,
|
||||
request.ExcludeCardIds ?? Array.Empty<long>(),
|
||||
ownedCardIds,
|
||||
_foils,
|
||||
_rng);
|
||||
var grant = await _acquisition.GrantManyAsync(viewerId, draw.Cards.Select(c => c.CardId));
|
||||
|
||||
// Accrue gacha points (skip tutorial path — the starter pack isn't a real open).
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Reads a JSON string OR number as a nullable string, tolerating prod's polymorphic id fields.
|
||||
/// <c>rotation_id</c> on a /load/index <c>UserDeck</c> is a numeric string ("10008") for real
|
||||
/// MyRotation decks but a bare number (<c>0</c>) for unset slots — and the global
|
||||
/// <c>AllowReadingFromString</c> only covers the string→number direction, not number→string, so a
|
||||
/// plain <c>string?</c> property 400s on the numeric form. Null stays null; numbers serialize via
|
||||
/// invariant culture so a captured <c>0</c> round-trips to <c>"0"</c>.
|
||||
/// </summary>
|
||||
public sealed class FlexibleStringConverter : JsonConverter<string?>
|
||||
{
|
||||
public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
|
||||
reader.TokenType switch
|
||||
{
|
||||
JsonTokenType.Null => null,
|
||||
JsonTokenType.String => reader.GetString(),
|
||||
JsonTokenType.Number when reader.TryGetInt64(out var n) => n.ToString(CultureInfo.InvariantCulture),
|
||||
JsonTokenType.Number => reader.GetDouble().ToString(CultureInfo.InvariantCulture),
|
||||
_ => throw new JsonException($"Unexpected token {reader.TokenType} for a string-or-number field.")
|
||||
};
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, string? value, JsonSerializerOptions options) =>
|
||||
writer.WriteStringValue(value);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Common;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Admin;
|
||||
|
||||
@@ -26,6 +27,29 @@ public class ImportViewerRequest
|
||||
[JsonPropertyName("owned_mypage_background_ids")] public List<int>? OwnedMyPageBackgroundIds { get; set; }
|
||||
|
||||
[JsonPropertyName("classes")] public List<ImportClassData>? Classes { get; set; }
|
||||
|
||||
[JsonPropertyName("owned_cards")] public List<ImportCard>? OwnedCards { get; set; }
|
||||
|
||||
[JsonPropertyName("items")] public List<ImportItem>? Items { get; set; }
|
||||
|
||||
[JsonPropertyName("decks")] public List<ImportDeck>? Decks { get; set; }
|
||||
}
|
||||
|
||||
public class ImportDeck
|
||||
{
|
||||
[JsonPropertyName("deck_format")] public int DeckFormat { get; set; } // wire code; map via FormatExtensions.FromApi
|
||||
[JsonPropertyName("deck_no")] public int DeckNo { get; set; }
|
||||
[JsonPropertyName("deck_name")] public string? DeckName { get; set; }
|
||||
[JsonPropertyName("class_id")] public int ClassId { get; set; }
|
||||
[JsonPropertyName("card_id_array")] public List<long>? CardIdArray { get; set; }
|
||||
[JsonPropertyName("sleeve_id")] public long? SleeveId { get; set; }
|
||||
[JsonPropertyName("leader_skin_id")] public int? LeaderSkinId { get; set; }
|
||||
[JsonPropertyName("is_random_leader_skin")] public int? IsRandomLeaderSkin { get; set; }
|
||||
// Prod emits rotation_id as a numeric string ("10008") for real MyRotation decks but a bare
|
||||
// number (0) for unset slots; FlexibleStringConverter accepts either (a plain string? 400s on
|
||||
// the numeric form because AllowReadingFromString only covers string→number).
|
||||
[JsonPropertyName("my_rotation_id")] [JsonConverter(typeof(FlexibleStringConverter))]
|
||||
public string? MyRotationId { get; set; }
|
||||
}
|
||||
|
||||
public class ImportCurrency
|
||||
@@ -41,3 +65,16 @@ public class ImportClassData
|
||||
[JsonPropertyName("level")] public int Level { get; set; }
|
||||
[JsonPropertyName("exp")] public int Exp { get; set; }
|
||||
}
|
||||
|
||||
public class ImportCard
|
||||
{
|
||||
[JsonPropertyName("card_id")] public long CardId { get; set; }
|
||||
[JsonPropertyName("count")] public int Count { get; set; }
|
||||
[JsonPropertyName("is_protected")] public bool IsProtected { get; set; }
|
||||
}
|
||||
|
||||
public class ImportItem
|
||||
{
|
||||
[JsonPropertyName("item_id")] public int ItemId { get; set; }
|
||||
[JsonPropertyName("count")] public int Count { get; set; }
|
||||
}
|
||||
|
||||
@@ -7,4 +7,5 @@ public class ImportViewerResponse
|
||||
[JsonPropertyName("viewer_id")] public long ViewerId { get; set; }
|
||||
[JsonPropertyName("short_udid")] public long ShortUdid { get; set; }
|
||||
[JsonPropertyName("was_created")] public bool WasCreated { get; set; }
|
||||
[JsonPropertyName("skipped_card_count")] public int SkippedCardCount { get; set; }
|
||||
}
|
||||
|
||||
@@ -74,12 +74,13 @@ public class Program
|
||||
builder.Services.AddTransient<IPuzzleCatalogRepository, PuzzleCatalogRepository>();
|
||||
builder.Services.AddTransient<IDeckRepository, DeckRepository>();
|
||||
builder.Services.AddTransient<IPackRepository, PackRepository>();
|
||||
builder.Services.AddScoped<SVSim.Database.Repositories.PackDrawTables.IPackDrawTableRepository, SVSim.Database.Repositories.PackDrawTables.PackDrawTableRepository>();
|
||||
builder.Services.AddTransient<IBuildDeckRepository, BuildDeckRepository>();
|
||||
// Scoped (not Singleton) to avoid the singleton-depends-on-scoped-DbContext lifecycle
|
||||
// pitfall. Cost: one indexed single-row query per section per request — trivial. No
|
||||
// in-process cache today; the IGameConfigService interface is shaped to allow one later.
|
||||
builder.Services.AddScoped<SVSim.Database.Services.IGameConfigService, GameConfigService>();
|
||||
builder.Services.AddScoped<ICardPoolProvider, DbCardPoolProvider>();
|
||||
builder.Services.AddScoped<ICardFoilLookup, DbCardFoilLookup>();
|
||||
builder.Services.AddScoped<PackOpenService>();
|
||||
builder.Services.AddScoped<IGachaPointService, GachaPointService>();
|
||||
builder.Services.AddScoped<ICardAcquisitionService, CardAcquisitionService>();
|
||||
|
||||
13
SVSim.EmulatedEntrypoint/Services/DbCardFoilLookup.cs
Normal file
13
SVSim.EmulatedEntrypoint/Services/DbCardFoilLookup.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
public class DbCardFoilLookup : ICardFoilLookup
|
||||
{
|
||||
private readonly SVSimDbContext _db;
|
||||
public DbCardFoilLookup(SVSimDbContext db) { _db = db; }
|
||||
|
||||
public ShadowverseCardEntry? TryGetFoilTwin(long baseCardId) =>
|
||||
_db.Cards.FirstOrDefault(c => c.Id == baseCardId + 1 && c.IsFoil);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
public class DbCardPoolProvider : ICardPoolProvider
|
||||
{
|
||||
private readonly SVSimDbContext _db;
|
||||
public DbCardPoolProvider(SVSimDbContext db) { _db = db; }
|
||||
|
||||
public IReadOnlyList<ShadowverseCardEntry> GetPool(PackConfigEntry pack)
|
||||
{
|
||||
switch (pack.PackCategory)
|
||||
{
|
||||
case PackCategory.None:
|
||||
case PackCategory.LegendCardPack:
|
||||
{
|
||||
var pool = _db.CardSets
|
||||
.Where(s => s.Id == pack.BasePackId)
|
||||
.SelectMany(s => s.Cards)
|
||||
.Where(c => !c.IsFoil)
|
||||
.ToList();
|
||||
if (pool.Count > 0) return pool;
|
||||
|
||||
// BasePackId 90001 (and the 9xxxx range generally) is a synthetic "Throwback
|
||||
// Rotation" category that doesn't have a corresponding real card_set in the
|
||||
// prod card master — its real pool is a curated subset of rotation-eligible
|
||||
// older sets (Altersphere–Colosseum for 99047; see the gacha_detail string).
|
||||
// We don't have that membership map, so fall back to all in-rotation cards.
|
||||
// Broader pool than prod but produces a valid 8-card draw, which is what the
|
||||
// tutorial flow needs to advance to step 100.
|
||||
// TODO: import the real Throwback Rotation card-set membership and key the
|
||||
// pool off that. Source data is in the client's pack-pool master, not yet
|
||||
// captured.
|
||||
return _db.CardSets
|
||||
.Where(s => s.IsInRotation)
|
||||
.SelectMany(s => s.Cards)
|
||||
.Where(c => !c.IsFoil)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
case PackCategory.SpecialCardPack:
|
||||
case PackCategory.LimitedSpecialCardPack:
|
||||
return _db.CardSets
|
||||
.Where(s => s.IsInRotation)
|
||||
.SelectMany(s => s.Cards)
|
||||
.Where(c => !c.IsFoil)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
default:
|
||||
return Array.Empty<ShadowverseCardEntry>();
|
||||
}
|
||||
}
|
||||
|
||||
public ShadowverseCardEntry? TryGetFoilTwin(long baseCardId) =>
|
||||
_db.Cards.FirstOrDefault(c => c.Id == baseCardId + 1 && c.IsFoil);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Repositories.PackDrawTables;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
|
||||
@@ -11,13 +12,13 @@ namespace SVSim.EmulatedEntrypoint.Services;
|
||||
public sealed class GachaPointService : IGachaPointService
|
||||
{
|
||||
private readonly SVSimDbContext _db;
|
||||
private readonly ICardPoolProvider _pools;
|
||||
private readonly IPackDrawTableRepository _drawTables;
|
||||
private readonly RewardGrantService _grants;
|
||||
|
||||
public GachaPointService(SVSimDbContext db, ICardPoolProvider pools, RewardGrantService grants)
|
||||
public GachaPointService(SVSimDbContext db, IPackDrawTableRepository drawTables, RewardGrantService grants)
|
||||
{
|
||||
_db = db;
|
||||
_pools = pools;
|
||||
_drawTables = drawTables;
|
||||
_grants = grants;
|
||||
}
|
||||
|
||||
@@ -26,7 +27,8 @@ public sealed class GachaPointService : IGachaPointService
|
||||
var pack = await _db.Packs.FirstOrDefaultAsync(p => p.Id == packId);
|
||||
if (pack?.GachaPointConfig is null) return Array.Empty<GachaPointRewardDto>();
|
||||
|
||||
var pool = _pools.GetPool(pack);
|
||||
var drawTable = await _drawTables.GetAsync(packId);
|
||||
if (drawTable is null) return Array.Empty<GachaPointRewardDto>();
|
||||
|
||||
// EF Core 8 has no ToHashSetAsync on IQueryable — materialize via ToListAsync then hash.
|
||||
var receivedCardIds = (await _db.Viewers
|
||||
@@ -36,9 +38,11 @@ public sealed class GachaPointService : IGachaPointService
|
||||
.Select(r => r.CardId)
|
||||
.ToListAsync()).ToHashSet();
|
||||
|
||||
var legendaryCardIds = pool
|
||||
.Where(c => c.Rarity == Rarity.Legendary && !c.IsFoil)
|
||||
.Select(c => c.Id)
|
||||
// Legendaries in the pack's draw table — exchange ignores foils (the alt-art foil
|
||||
// printing is gated separately) and tiers other than Legendary.
|
||||
var legendaryCardIds = drawTable.CardWeights
|
||||
.Where(w => w.Tier == DrawTier.Legendary && !w.IsAltArt)
|
||||
.Select(w => w.CardId)
|
||||
.ToHashSet();
|
||||
|
||||
// Re-query legendaries with Class loaded — pool provider doesn't include navs,
|
||||
|
||||
13
SVSim.EmulatedEntrypoint/Services/ICardFoilLookup.cs
Normal file
13
SVSim.EmulatedEntrypoint/Services/ICardFoilLookup.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Looks up the foil printing of a card. The card-master convention is foil_id = base_id + 1
|
||||
/// with the IsFoil flag set; leader-card / alt-art printings typically have no foil twin
|
||||
/// (TryGetFoilTwin returns null and the sampler silently keeps the base).
|
||||
/// </summary>
|
||||
public interface ICardFoilLookup
|
||||
{
|
||||
ShadowverseCardEntry? TryGetFoilTwin(long baseCardId);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
/// <summary>Resolves the card pool a pack draws from. Pure function over master data.</summary>
|
||||
public interface ICardPoolProvider
|
||||
{
|
||||
IReadOnlyList<ShadowverseCardEntry> GetPool(PackConfigEntry pack);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the foil twin of <paramref name="baseCardId"/> if it exists in master data
|
||||
/// (foil card_id = base card_id + 1 by the cards.json convention), else null. One DB
|
||||
/// hit per call; expected ~0.64 calls per 8-card pack at the default 8% rate.
|
||||
/// TODO(caching): folds into the broader caching wave once one exists.
|
||||
/// </summary>
|
||||
ShadowverseCardEntry? TryGetFoilTwin(long baseCardId);
|
||||
}
|
||||
@@ -1,134 +1,152 @@
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Models.Config;
|
||||
using SVSim.Database.Repositories.PackDrawTables;
|
||||
using SVSim.Database.Services;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Draws cards from a pack's pool using rates from <see cref="IGameConfigService"/>'s
|
||||
/// <see cref="PackRateConfig"/>. Slot rarity selection is unified through one
|
||||
/// <see cref="PickRarity"/> + <see cref="ResolveWeights"/> pair — what was previously a
|
||||
/// hardcoded slot-1-7 vs slot-8 split now reads from <c>PackRateConfig.PerSlot</c>.
|
||||
///
|
||||
/// The "legendary-special slot-8 forced Legendary" rule stays in code (structural category
|
||||
/// promise, not a tunable rate).
|
||||
/// Draws cards from a pack's per-pack draw table. Slot tier and per-card weights are sampled
|
||||
/// directly from the seeded data. The bonus slot fires once at the end of a 10-pack open
|
||||
/// when <see cref="PackDrawConfigEntry.HasBonusSlot"/> is set.
|
||||
/// </summary>
|
||||
public class PackOpenService
|
||||
{
|
||||
private const int CardsPerPack = 8;
|
||||
|
||||
private readonly PackRateConfig _rates;
|
||||
|
||||
public PackOpenService(IGameConfigService config)
|
||||
{
|
||||
_rates = config.Get<PackRateConfig>();
|
||||
}
|
||||
|
||||
public DrawResult Draw(
|
||||
PackDrawTable drawTable,
|
||||
PackConfigEntry pack,
|
||||
ICardPoolProvider pools,
|
||||
int packNumber,
|
||||
IReadOnlyCollection<long> excludeCardIds,
|
||||
IReadOnlyCollection<long> ownedCardIds,
|
||||
ICardFoilLookup foilLookup,
|
||||
IRandom rng)
|
||||
{
|
||||
var pool = pools.GetPool(pack);
|
||||
if (pool.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"PackOpenService: pool for pack {pack.Id} (category {pack.PackCategory}) is empty.");
|
||||
}
|
||||
|
||||
var poolByRarity = pool
|
||||
.Where(c => !excludeCardIds.Contains(c.Id))
|
||||
.GroupBy(c => c.Rarity)
|
||||
var byKey = drawTable.CardWeights
|
||||
.GroupBy(w => (w.Slot, w.Tier))
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
bool isLegendarySpecial =
|
||||
pack.PackCategory == PackCategory.SpecialCardPack ||
|
||||
pack.PackCategory == PackCategory.LimitedSpecialCardPack;
|
||||
var slotRatesByKey = drawTable.SlotRates
|
||||
.GroupBy(s => s.Slot)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
var slots = new List<DrawnCard>(packNumber * CardsPerPack + 1);
|
||||
|
||||
var slots = new List<DrawnCard>(packNumber * CardsPerPack);
|
||||
for (int p = 0; p < packNumber; p++)
|
||||
{
|
||||
for (int s = 0; s < CardsPerPack; s++)
|
||||
{
|
||||
int slotNum = s + 1; // 1-based
|
||||
|
||||
Rarity rarity;
|
||||
if (slotNum == CardsPerPack && isLegendarySpecial)
|
||||
{
|
||||
// Structural category rule (not a tunable rate).
|
||||
rarity = Rarity.Legendary;
|
||||
}
|
||||
else
|
||||
{
|
||||
rarity = PickRarity(rng, ResolveWeights(slotNum));
|
||||
}
|
||||
|
||||
var card = PickCardOfRarity(rarity, poolByRarity, rng);
|
||||
|
||||
// Per-card, per-slot animated upgrade. Applies independently of rarity, slot
|
||||
// position, and pack category — including forced-Legendary slot-8 of specials.
|
||||
if (rng.NextDouble() < _rates.AnimatedRate)
|
||||
{
|
||||
var foil = pools.TryGetFoilTwin(card.Id);
|
||||
if (foil is not null) card = foil; // silently keep base if no twin exists
|
||||
}
|
||||
|
||||
slots.Add(new DrawnCard(card.Id, card.Rarity));
|
||||
int slotNum = s + 1;
|
||||
var slot = slotNum == CardsPerPack ? DrawSlot.Eighth : DrawSlot.General;
|
||||
var drawn = DrawOne(slot, drawTable, byKey, slotRatesByKey,
|
||||
excludeCardIds, ownedCardIds, foilLookup, rng);
|
||||
slots.Add(drawn);
|
||||
}
|
||||
}
|
||||
|
||||
if (drawTable.Config.HasBonusSlot && packNumber == 10)
|
||||
{
|
||||
var bonus = DrawOne(DrawSlot.Bonus, drawTable, byKey, slotRatesByKey,
|
||||
excludeCardIds, ownedCardIds, foilLookup, rng);
|
||||
slots.Add(bonus);
|
||||
}
|
||||
|
||||
return new DrawResult(slots);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the rarity weights for the given 1-based slot. Looks for a per-slot override
|
||||
/// keyed by <c>Slot == slotNum.ToString()</c>; falls back to the global Default.
|
||||
///
|
||||
/// NOTE: PerSlot is List<SlotRarityWeights> (not Dictionary) due to an EF Core 8
|
||||
/// jsonb-mapping limitation. Per-pack overrides would extend this resolver to check a
|
||||
/// per-pack collection first.
|
||||
/// </summary>
|
||||
private SlotRarityWeights ResolveWeights(int slotNum)
|
||||
{
|
||||
var slotKey = slotNum.ToString();
|
||||
var perSlot = _rates.PerSlot.FirstOrDefault(s => s.Slot == slotKey);
|
||||
return perSlot ?? _rates.Default;
|
||||
}
|
||||
|
||||
private static Rarity PickRarity(IRandom rng, SlotRarityWeights w)
|
||||
{
|
||||
// Cumulative-band order: Legendary -> Gold -> Silver -> Bronze (catch-all).
|
||||
// - When weights sum to <1.0 (SV Classic Default = 0.9994), the slack absorbs into
|
||||
// Bronze via the catch-all — preserves historic behavior.
|
||||
// - When weights sum to exactly 1.0 (SV Classic PerSlot[8] with Bronze=0), the catch-all
|
||||
// never fires and Bronze=0 holds naturally.
|
||||
double r = rng.NextDouble();
|
||||
double cum = w.Legendary; if (r < cum) return Rarity.Legendary;
|
||||
cum += w.Gold; if (r < cum) return Rarity.Gold;
|
||||
cum += w.Silver; if (r < cum) return Rarity.Silver;
|
||||
return Rarity.Bronze;
|
||||
}
|
||||
|
||||
private static ShadowverseCardEntry PickCardOfRarity(
|
||||
Rarity rarity,
|
||||
Dictionary<Rarity, List<ShadowverseCardEntry>> poolByRarity,
|
||||
private static DrawnCard DrawOne(
|
||||
DrawSlot slot,
|
||||
PackDrawTable drawTable,
|
||||
Dictionary<(DrawSlot, DrawTier), List<PackDrawCardWeightEntry>> byKey,
|
||||
Dictionary<DrawSlot, List<PackDrawSlotRateEntry>> slotRatesByKey,
|
||||
IReadOnlyCollection<long> excludeCardIds,
|
||||
IReadOnlyCollection<long> ownedCardIds,
|
||||
ICardFoilLookup foilLookup,
|
||||
IRandom rng)
|
||||
{
|
||||
// Fallback if the rolled rarity has no cards: walk down (and up) through all rarities.
|
||||
// Order: rolled rarity first, then Legendary -> Gold -> Silver -> Bronze, deduped by
|
||||
// LINQ Distinct. This handles both "no Legendaries" (fall down) and sparse pools that
|
||||
// only contain a single rarity (fall up). Safety net for sparse master data.
|
||||
Rarity[] fallback = new[] { rarity, Rarity.Legendary, Rarity.Gold, Rarity.Silver, Rarity.Bronze }
|
||||
.Distinct().ToArray();
|
||||
foreach (var r in fallback)
|
||||
var slotRates = slotRatesByKey.TryGetValue(slot, out var sr) ? sr : new();
|
||||
if (slotRates.Count == 0)
|
||||
throw new InvalidOperationException(
|
||||
$"PackOpenService: no slot rates for pack {drawTable.Config.Id} slot {slot}");
|
||||
|
||||
var tiers = slotRates.Select(r => r.Tier).ToList();
|
||||
var tierWeights = slotRates.Select(r => r.RatePct).ToList();
|
||||
var tier = WeightedPick.Pick(rng, tiers, tierWeights);
|
||||
|
||||
// For slot 8 (and bonus), drawrates pages often quote per-rarity slot rates but no per-card
|
||||
// breakdown — the card pool is the same as the general slot's per-tier pool. Fall back to
|
||||
// (General, tier) when (slot, tier) has no card weights.
|
||||
if (!byKey.TryGetValue((slot, tier), out var rows) && slot != DrawSlot.General)
|
||||
{
|
||||
if (poolByRarity.TryGetValue(r, out var list) && list.Count > 0)
|
||||
{
|
||||
return list[rng.Next(list.Count)];
|
||||
}
|
||||
byKey.TryGetValue((DrawSlot.General, tier), out rows);
|
||||
}
|
||||
throw new InvalidOperationException("PackOpenService: pool empty after exclude filter.");
|
||||
var pool = rows ?? new();
|
||||
var filtered = pool.Where(w => !excludeCardIds.Contains(w.CardId)).ToList();
|
||||
|
||||
if (filtered.Count == 0)
|
||||
return FallbackAcrossTiers(slot, byKey, excludeCardIds, foilLookup, rng, drawTable);
|
||||
|
||||
bool rateLess = filtered.All(w => w.RatePct == null);
|
||||
|
||||
PackDrawCardWeightEntry picked;
|
||||
if (rateLess)
|
||||
{
|
||||
var unowned = filtered.Where(w => !ownedCardIds.Contains(w.CardId)).ToList();
|
||||
var sourcePool = unowned.Count > 0 ? unowned : filtered;
|
||||
picked = sourcePool[rng.Next(sourcePool.Count)];
|
||||
}
|
||||
else
|
||||
{
|
||||
var metered = filtered.Where(w => w.RatePct.HasValue).ToList();
|
||||
if (metered.Count == 0)
|
||||
return FallbackAcrossTiers(slot, byKey, excludeCardIds, foilLookup, rng, drawTable);
|
||||
picked = WeightedPick.Pick(rng, metered, metered.Select(w => w.RatePct!.Value).ToList());
|
||||
}
|
||||
|
||||
long cardId = picked.CardId;
|
||||
|
||||
if (drawTable.Config.AnimationRatePct > 0
|
||||
&& rng.NextDouble() < drawTable.Config.AnimationRatePct / 100.0)
|
||||
{
|
||||
var foil = foilLookup.TryGetFoilTwin(cardId);
|
||||
if (foil is not null) cardId = foil.Id;
|
||||
}
|
||||
|
||||
var rarity = TierToRarity(picked);
|
||||
return new DrawnCard(cardId, rarity);
|
||||
}
|
||||
|
||||
private static DrawnCard FallbackAcrossTiers(
|
||||
DrawSlot slot,
|
||||
Dictionary<(DrawSlot, DrawTier), List<PackDrawCardWeightEntry>> byKey,
|
||||
IReadOnlyCollection<long> excludeCardIds,
|
||||
ICardFoilLookup foilLookup,
|
||||
IRandom rng,
|
||||
PackDrawTable drawTable)
|
||||
{
|
||||
foreach (var tier in new[] { DrawTier.Legendary, DrawTier.Gold, DrawTier.Silver, DrawTier.Bronze, DrawTier.Special })
|
||||
{
|
||||
if (!byKey.TryGetValue((slot, tier), out var rows)) continue;
|
||||
var filtered = rows.Where(w => !excludeCardIds.Contains(w.CardId)).ToList();
|
||||
if (filtered.Count == 0) continue;
|
||||
var picked = filtered[rng.Next(filtered.Count)];
|
||||
return new DrawnCard(picked.CardId, TierToRarity(picked));
|
||||
}
|
||||
throw new InvalidOperationException(
|
||||
$"PackOpenService: pool empty after exclude filter for pack {drawTable.Config.Id} slot {slot}.");
|
||||
}
|
||||
|
||||
private static Rarity TierToRarity(PackDrawCardWeightEntry w) => w.Tier switch
|
||||
{
|
||||
DrawTier.Bronze => Rarity.Bronze,
|
||||
DrawTier.Silver => Rarity.Silver,
|
||||
DrawTier.Gold => Rarity.Gold,
|
||||
DrawTier.Legendary => Rarity.Legendary,
|
||||
// Special tier cards typically have intrinsic Rarity.Legendary; the wire response
|
||||
// surfaces Rarity as an int for client coloring and the card_id is the source of
|
||||
// truth for what's granted.
|
||||
DrawTier.Special => Rarity.Legendary,
|
||||
_ => Rarity.Bronze,
|
||||
};
|
||||
}
|
||||
|
||||
30
SVSim.EmulatedEntrypoint/Services/WeightedPick.cs
Normal file
30
SVSim.EmulatedEntrypoint/Services/WeightedPick.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using SVSim.Database.Services;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Generic cumulative-band weighted picker used by PackOpenService for tier-by-slot
|
||||
/// and card-within-tier sampling. Renormalizes weights internally (sums <1 absorb
|
||||
/// into the last band; sums >1 scale down).
|
||||
/// </summary>
|
||||
public static class WeightedPick
|
||||
{
|
||||
public static T Pick<T>(IRandom rng, IReadOnlyList<T> items, IReadOnlyList<double> weights)
|
||||
{
|
||||
if (items.Count == 0) throw new ArgumentException("WeightedPick: items is empty.");
|
||||
if (items.Count != weights.Count) throw new ArgumentException("WeightedPick: items / weights length mismatch.");
|
||||
|
||||
double sum = 0;
|
||||
for (int i = 0; i < weights.Count; i++) sum += weights[i];
|
||||
if (sum <= 0) return items[rng.Next(items.Count)];
|
||||
|
||||
double r = rng.NextDouble() * sum;
|
||||
double cum = 0;
|
||||
for (int i = 0; i < items.Count - 1; i++)
|
||||
{
|
||||
cum += weights[i];
|
||||
if (r < cum) return items[i];
|
||||
}
|
||||
return items[^1];
|
||||
}
|
||||
}
|
||||
@@ -115,4 +115,339 @@ public class AdminControllerTests
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ImportViewer_imports_owned_cards_and_skips_unknown()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
// 10001001 is in the minimal test card set; 99999999 is not.
|
||||
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
{
|
||||
SteamId = 76_561_198_111_222_333UL,
|
||||
OwnedCards = new List<ImportCard>
|
||||
{
|
||||
new() { CardId = 10001001L, Count = 2, IsProtected = true },
|
||||
new() { CardId = 99999999L, Count = 1, IsProtected = false },
|
||||
}
|
||||
});
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK),
|
||||
await response.Content.ReadAsStringAsync());
|
||||
var body = await response.Content.ReadFromJsonAsync<ImportViewerResponse>(JsonOptions);
|
||||
Assert.That(body!.SkippedCardCount, Is.EqualTo(1), "Unknown 99999999 must be skipped and counted.");
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var stored = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card)
|
||||
.FirstAsync(v => v.Id == body.ViewerId);
|
||||
|
||||
Assert.That(stored.Cards.Count, Is.EqualTo(1), "Only the known card should be stored.");
|
||||
var owned = stored.Cards.Single();
|
||||
Assert.That(owned.Card.Id, Is.EqualTo(10001001L));
|
||||
Assert.That(owned.Count, Is.EqualTo(2));
|
||||
Assert.That(owned.IsProtected, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ImportViewer_clamps_card_count_to_max_copies()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
{
|
||||
SteamId = 76_561_198_111_222_334UL,
|
||||
OwnedCards = new List<ImportCard> { new() { CardId = 10001002L, Count = 5 } }
|
||||
});
|
||||
var body = await response.Content.ReadFromJsonAsync<ImportViewerResponse>(JsonOptions);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var stored = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card)
|
||||
.FirstAsync(v => v.Id == body!.ViewerId);
|
||||
Assert.That(stored.Cards.Single().Count, Is.EqualTo(3),
|
||||
"Count must clamp to OwnedCardEntry.MaxCopies (3).");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ImportViewer_replaces_existing_card_collection()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
const ulong steamId = 76_561_198_111_222_335UL;
|
||||
long viewerId = await factory.SeedViewerAsync(steamId: steamId);
|
||||
await factory.SeedOwnedCardAsync(viewerId, 10001001L, count: 3);
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
{
|
||||
SteamId = steamId,
|
||||
OwnedCards = new List<ImportCard> { new() { CardId = 10001002L, Count = 1 } }
|
||||
});
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK),
|
||||
await response.Content.ReadAsStringAsync());
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var stored = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card)
|
||||
.FirstAsync(v => v.Id == viewerId);
|
||||
Assert.That(stored.Cards.Select(c => c.Card.Id), Is.EquivalentTo(new[] { 10001002L }),
|
||||
"Full replace: the pre-seeded 10001001 must be gone, only 10001002 present.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ImportViewer_imports_items_and_replaces_existing()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
const ulong steamId = 76_561_198_111_222_336UL;
|
||||
long viewerId = await factory.SeedViewerAsync(steamId: steamId);
|
||||
// Registers the ItemEntry master row (70001) and gives an initial owned count to be replaced.
|
||||
await factory.SeedOwnedItemAsync(viewerId, itemId: 70001, count: 1);
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
{
|
||||
SteamId = steamId,
|
||||
Items = new List<ImportItem>
|
||||
{
|
||||
new() { ItemId = 70001, Count = 5 },
|
||||
new() { ItemId = 88888, Count = 9 }, // unknown master id -> skipped silently
|
||||
}
|
||||
});
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK),
|
||||
await response.Content.ReadAsStringAsync());
|
||||
Assert.That(await factory.GetOwnedItemCountAsync(viewerId, 70001), Is.EqualTo(5),
|
||||
"Full replace: 70001 count updated to 5.");
|
||||
Assert.That(await factory.GetOwnedItemCountAsync(viewerId, 88888), Is.EqualTo(0),
|
||||
"Unknown item master id must not be inserted.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ImportViewer_imports_deck_with_correct_format_and_skips_unknown_cards()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
const ulong steamId = 76_561_198_111_222_337UL;
|
||||
long viewerId = await factory.SeedViewerAsync(steamId: steamId);
|
||||
|
||||
int classId, leaderSkinId; long sleeveId;
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
classId = (await db.Classes.FirstAsync()).Id;
|
||||
sleeveId = (await db.Sleeves.FirstAsync()).Id;
|
||||
leaderSkinId = (await db.LeaderSkins.FirstAsync()).Id;
|
||||
}
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
{
|
||||
SteamId = steamId,
|
||||
Decks = new List<ImportDeck>
|
||||
{
|
||||
new()
|
||||
{
|
||||
DeckFormat = 1, // wire Rotation
|
||||
DeckNo = 1,
|
||||
DeckName = "Imported Rotation",
|
||||
ClassId = classId,
|
||||
SleeveId = sleeveId,
|
||||
LeaderSkinId = leaderSkinId,
|
||||
CardIdArray = new List<long> { 10001001L, 10001001L, 99999999L }, // last is unknown
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK),
|
||||
await response.Content.ReadAsStringAsync());
|
||||
var body = await response.Content.ReadFromJsonAsync<ImportViewerResponse>(JsonOptions);
|
||||
Assert.That(body!.SkippedCardCount, Is.EqualTo(1), "Unknown deck card 99999999 counts as skipped.");
|
||||
|
||||
using var scope2 = factory.Services.CreateScope();
|
||||
var db2 = scope2.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var stored = await db2.Viewers
|
||||
.Include(v => v.Decks).ThenInclude(d => d.Cards).ThenInclude(c => c.Card)
|
||||
.FirstAsync(v => v.Id == viewerId);
|
||||
var deck = stored.Decks.Single(d => d.Name == "Imported Rotation");
|
||||
Assert.That(deck.Format, Is.EqualTo(Format.Rotation));
|
||||
Assert.That(deck.Cards.Single().Card.Id, Is.EqualTo(10001001L));
|
||||
Assert.That(deck.Cards.Single().Count, Is.EqualTo(2), "Two copies of 10001001 grouped.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ImportViewer_myrotation_deck_gets_rotation_id()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
await factory.SeedGlobalsAsync(); // populates MyRotationSettings
|
||||
const ulong steamId = 76_561_198_111_222_338UL;
|
||||
long viewerId = await factory.SeedViewerAsync(steamId: steamId);
|
||||
|
||||
int classId, leaderSkinId; long sleeveId;
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
classId = (await db.Classes.FirstAsync()).Id;
|
||||
sleeveId = (await db.Sleeves.FirstAsync()).Id;
|
||||
leaderSkinId = (await db.LeaderSkins.FirstAsync()).Id;
|
||||
}
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
{
|
||||
SteamId = steamId,
|
||||
Decks = new List<ImportDeck>
|
||||
{
|
||||
new()
|
||||
{
|
||||
DeckFormat = 5, // wire MyRotation
|
||||
DeckNo = 1,
|
||||
DeckName = "Imported MyRot",
|
||||
ClassId = classId,
|
||||
SleeveId = sleeveId,
|
||||
LeaderSkinId = leaderSkinId,
|
||||
CardIdArray = new List<long> { 10001001L },
|
||||
}
|
||||
}
|
||||
});
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK),
|
||||
await response.Content.ReadAsStringAsync());
|
||||
var body = await response.Content.ReadFromJsonAsync<ImportViewerResponse>(JsonOptions);
|
||||
|
||||
using var scope2 = factory.Services.CreateScope();
|
||||
var db2 = scope2.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var deck = await db2.Set<SVSim.Database.Models.ShadowverseDeckEntry>()
|
||||
.FirstAsync(d => d.Name == "Imported MyRot");
|
||||
Assert.That(deck.Format, Is.EqualTo(Format.MyRotation));
|
||||
Assert.That(deck.MyRotationId, Is.Not.Null.And.Not.Empty,
|
||||
"MyRotation decks need a rotation id or the client NREs on click.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ImportViewer_fresh_user_has_no_decks_when_none_imported()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
{
|
||||
SteamId = 76_561_198_111_222_339UL,
|
||||
DisplayName = "No Decks"
|
||||
});
|
||||
var body = await response.Content.ReadFromJsonAsync<ImportViewerResponse>(JsonOptions);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var stored = await db.Viewers.Include(v => v.Decks).FirstAsync(v => v.Id == body!.ViewerId);
|
||||
Assert.That(stored.Decks, Is.Empty,
|
||||
"Default-deck cloning was removed; a fresh viewer with no imported decks has none.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ImportViewer_binds_new_fields_from_literal_client_json()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
const ulong steamId = 76_561_198_111_222_340UL;
|
||||
long viewerId = await factory.SeedViewerAsync(steamId: steamId);
|
||||
await factory.SeedOwnedItemAsync(viewerId, itemId: 70001, count: 0); // register item master
|
||||
|
||||
int classId, leaderSkinId; long sleeveId;
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
classId = (await db.Classes.FirstAsync()).Id;
|
||||
sleeveId = (await db.Sleeves.FirstAsync()).Id;
|
||||
leaderSkinId = (await db.LeaderSkins.FirstAsync()).Id;
|
||||
}
|
||||
|
||||
string json = $$"""
|
||||
{
|
||||
"steam_id": {{steamId}},
|
||||
"owned_cards": [ { "card_id": 10001001, "count": 2, "is_protected": true } ],
|
||||
"items": [ { "item_id": 70001, "count": 4 } ],
|
||||
"decks": [ {
|
||||
"deck_format": 1,
|
||||
"deck_no": 2,
|
||||
"deck_name": "Wire Deck",
|
||||
"class_id": {{classId}},
|
||||
"sleeve_id": {{sleeveId}},
|
||||
"leader_skin_id": {{leaderSkinId}},
|
||||
"is_random_leader_skin": 0,
|
||||
"card_id_array": [10001001, 10001002]
|
||||
} ]
|
||||
}
|
||||
""";
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
using var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
|
||||
var response = await client.PostAsync("/admin/import_viewer", content);
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK),
|
||||
await response.Content.ReadAsStringAsync());
|
||||
|
||||
using var scope2 = factory.Services.CreateScope();
|
||||
var db2 = scope2.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var stored = await db2.Viewers
|
||||
.Include(v => v.Cards).ThenInclude(c => c.Card)
|
||||
.Include(v => v.Items).ThenInclude(i => i.Item)
|
||||
.Include(v => v.Decks)
|
||||
.FirstAsync(v => v.Id == viewerId);
|
||||
|
||||
Assert.That(stored.Cards.Any(c => c.Card.Id == 10001001L && c.Count == 2 && c.IsProtected), Is.True,
|
||||
"owned_cards snake_case keys must bind (card_id/count/is_protected).");
|
||||
Assert.That(stored.Items.Any(i => i.Item.Id == 70001 && i.Count == 4), Is.True,
|
||||
"items snake_case keys must bind (item_id/count).");
|
||||
Assert.That(stored.Decks.Any(d => d.Name == "Wire Deck" && d.Format == Format.Rotation), Is.True,
|
||||
"decks snake_case keys must bind (deck_format/deck_no/class_id/card_id_array/...).");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ImportViewer_tolerates_numeric_my_rotation_id_and_skips_empty_decks()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
const ulong steamId = 76_561_198_111_222_341UL;
|
||||
long viewerId = await factory.SeedViewerAsync(steamId: steamId);
|
||||
|
||||
int classId, leaderSkinId; long sleeveId;
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
classId = (await db.Classes.FirstAsync()).Id;
|
||||
sleeveId = (await db.Sleeves.FirstAsync()).Id;
|
||||
leaderSkinId = (await db.LeaderSkins.FirstAsync()).Id;
|
||||
}
|
||||
|
||||
// Mirrors a real prod dump: empty MyRotation slots carry "my_rotation_id": 0 (a NUMBER,
|
||||
// not a string), and dozens of empty slots accompany the few real decks.
|
||||
string json = $$"""
|
||||
{
|
||||
"steam_id": {{steamId}},
|
||||
"decks": [
|
||||
{ "deck_format": 2, "deck_no": 1, "deck_name": "Real", "class_id": {{classId}},
|
||||
"sleeve_id": {{sleeveId}}, "leader_skin_id": {{leaderSkinId}},
|
||||
"is_random_leader_skin": 0, "card_id_array": [10001001, 10001002] },
|
||||
{ "deck_format": 5, "deck_no": 1, "deck_name": "", "class_id": 0,
|
||||
"sleeve_id": 3000011, "leader_skin_id": 0, "is_random_leader_skin": 0,
|
||||
"my_rotation_id": 0, "card_id_array": [] },
|
||||
{ "deck_format": 1, "deck_no": 3, "deck_name": "", "class_id": 1,
|
||||
"sleeve_id": 3000011, "leader_skin_id": 0, "is_random_leader_skin": 0,
|
||||
"card_id_array": [] }
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
using var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
|
||||
var response = await client.PostAsync("/admin/import_viewer", content);
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK),
|
||||
await response.Content.ReadAsStringAsync());
|
||||
|
||||
using var scope2 = factory.Services.CreateScope();
|
||||
var db2 = scope2.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var stored = await db2.Viewers.Include(v => v.Decks).FirstAsync(v => v.Id == viewerId);
|
||||
Assert.That(stored.Decks.Count, Is.EqualTo(1),
|
||||
"Empty deck slots must be skipped; only the real (non-empty) deck imports.");
|
||||
Assert.That(stored.Decks.Single().Name, Is.EqualTo("Real"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ public class PackControllerGachaPointTests
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
await factory.SeedPackDrawTableFromSetAsync(10008, 10008);
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var body = JsonBody("""{"odds_gacha_id":10008,"parent_gacha_id":10008,"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""");
|
||||
@@ -95,6 +96,7 @@ public class PackControllerGachaPointTests
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
await factory.SeedPackDrawTableFromSetAsync(10008, 10008);
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var body = JsonBody("""{"odds_gacha_id":10008,"parent_gacha_id":10008,"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""");
|
||||
@@ -145,6 +147,7 @@ public class PackControllerGachaPointTests
|
||||
viewer.GachaPointBalances.Add(new ViewerGachaPointBalance { PackId = 10008, Points = 500 });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
await factory.SeedPackDrawTableFromSetAsync(10008, 10008);
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var body = JsonBody("""{"card_id":108041010,"parent_gacha_id":10008,"odds_gacha_id":10008,"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""");
|
||||
@@ -213,6 +216,7 @@ public class PackControllerGachaPointTests
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
await factory.SeedPackDrawTableFromSetAsync(10008, 10008);
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var body = JsonBody("""{"card_id":108041010,"parent_gacha_id":10008,"odds_gacha_id":10008,"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""");
|
||||
|
||||
@@ -369,6 +369,7 @@ public class PackControllerOpenTests
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
int parentGachaId = await SeedSingleLeaderCardPack(factory, LeaderCardId);
|
||||
await factory.SeedPackDrawTableAsync(parentGachaId, LeaderCardId);
|
||||
await SeedCosmeticMapping(factory, LeaderCardId, SkinId);
|
||||
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
@@ -507,6 +508,10 @@ public class PackControllerOpenTests
|
||||
viewer.Currency.Rupees = 10000;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
// 30 card stubs were seeded above (Ids 108041010..108041039); install a draw table
|
||||
// pointing the pack at those so the sampler picks from real test cards.
|
||||
var seededCardIds = Enumerable.Range(0, 30).Select(i => (long)(10804_1010 + i)).ToArray();
|
||||
await factory.SeedPackDrawTableAsync(10008, seededCardIds);
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var body = new StringContent(
|
||||
@@ -588,6 +593,9 @@ public class PackControllerOpenTests
|
||||
viewer.MissionData.TutorialState = 41; // pre-END so the tutorial path is allowed
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
// Install a draw table for 99047 pointing at the 30 seeded card stubs.
|
||||
var seededCardIds = Enumerable.Range(0, 30).Select(i => (long)(99047_1010 + i)).ToArray();
|
||||
await factory.SeedPackDrawTableAsync(99047, seededCardIds);
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var body = new StringContent(
|
||||
|
||||
@@ -97,6 +97,8 @@ public class PackControllerTests
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
// Install a draw table for 99047 pointing at the seeded starter cards.
|
||||
await factory.SeedPackDrawTableAsync(99047, 90001001L, 90001002L, 90001003L);
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
|
||||
@@ -42,6 +42,8 @@ public class TutorialFlowEndToEndTests
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
// Install a draw table for 99047 pointing at the seeded starter cards.
|
||||
await factory.SeedPackDrawTableAsync(99047, 90001001L, 90001002L, 90001003L);
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
|
||||
51
SVSim.UnitTests/Importers/PackDrawTableImporterTests.cs
Normal file
51
SVSim.UnitTests/Importers/PackDrawTableImporterTests.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SVSim.Bootstrap.Importers;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
|
||||
namespace SVSim.UnitTests.Importers;
|
||||
|
||||
public class PackDrawTableImporterTests
|
||||
{
|
||||
private static string SeedDir => Path.Combine(AppContext.BaseDirectory, "Data", "seeds");
|
||||
|
||||
[Test]
|
||||
public async Task Imports_config_slot_rates_and_card_weights()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
|
||||
await new PackDrawTableImporter().ImportAsync(db, SeedDir);
|
||||
|
||||
// Production seed is the source of truth in test output (no test-fixture overlay).
|
||||
Assert.That(await db.PackDrawConfigs.CountAsync(), Is.GreaterThanOrEqualTo(200));
|
||||
Assert.That(await db.PackDrawSlotRates.CountAsync(), Is.GreaterThanOrEqualTo(1000));
|
||||
Assert.That(await db.PackDrawCardWeights.CountAsync(), Is.GreaterThanOrEqualTo(50_000));
|
||||
|
||||
// 98001 is a Guaranteed-Leader-Card bundle — bonus slot must contain rate-less
|
||||
// Special-tier leader rows.
|
||||
var bonus = await db.PackDrawCardWeights
|
||||
.Where(w => w.PackId == 98001 && w.Slot == DrawSlot.Bonus)
|
||||
.ToListAsync();
|
||||
Assert.That(bonus.Count, Is.GreaterThan(0));
|
||||
Assert.That(bonus.All(w => w.RatePct == null && w.IsLeader && w.Tier == DrawTier.Special), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Is_idempotent_on_rerun()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
|
||||
await new PackDrawTableImporter().ImportAsync(db, SeedDir);
|
||||
int before = await db.PackDrawCardWeights.CountAsync();
|
||||
await new PackDrawTableImporter().ImportAsync(db, SeedDir);
|
||||
int after = await db.PackDrawCardWeights.CountAsync();
|
||||
|
||||
Assert.That(after, Is.EqualTo(before));
|
||||
}
|
||||
}
|
||||
44
SVSim.UnitTests/Importers/PackImporterStubsTests.cs
Normal file
44
SVSim.UnitTests/Importers/PackImporterStubsTests.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SVSim.Bootstrap.Importers;
|
||||
using SVSim.Database;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
|
||||
namespace SVSim.UnitTests.Importers;
|
||||
|
||||
public class PackImporterStubsTests
|
||||
{
|
||||
private static string SeedDir => Path.Combine(AppContext.BaseDirectory, "Data", "seeds");
|
||||
|
||||
[Test]
|
||||
public async Task Live_capture_overrides_stub_on_conflict()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
|
||||
await new PackImporter().ImportAsync(db, SeedDir);
|
||||
|
||||
// 10001 is in both packs.json (no is_enabled -> defaults true) and pack-stubs.json
|
||||
// (is_enabled=false). Live capture wins -> IsEnabled stays true and gacha_detail
|
||||
// is the packs.json value, not "STUB CLC".
|
||||
var live = await db.Packs.FirstAsync(p => p.Id == 10001);
|
||||
Assert.That(live.IsEnabled, Is.True);
|
||||
Assert.That(live.GachaDetail, Does.Not.Contain("STUB"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Stub_only_packs_are_inserted_with_IsEnabled_false()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
|
||||
await new PackImporter().ImportAsync(db, SeedDir);
|
||||
|
||||
// 95001 is stub-only -> inserted with IsEnabled=false and the stub's gacha_detail.
|
||||
var stub = await db.Packs.FirstAsync(p => p.Id == 95001);
|
||||
Assert.That(stub.IsEnabled, Is.False);
|
||||
Assert.That(stub.GachaDetail, Is.EqualTo("7th Anniv stub"));
|
||||
}
|
||||
}
|
||||
@@ -70,10 +70,32 @@ internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
|
||||
// IsInRotation so both standard-pack (by setId) and special-pack (rotation scan)
|
||||
// tests see real data.
|
||||
SeedMinimalCardSet(db);
|
||||
SeedMinimalPackDrawTable(db);
|
||||
|
||||
return host;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds a minimal PackDrawConfig + slot rates + card weights for the test card-set's
|
||||
/// cards (10001001/10001002/10001003) under pack id 10001. Lets PackController.Open
|
||||
/// resolve a draw table without requiring tests to run the full PackDrawTableImporter.
|
||||
/// </summary>
|
||||
private static void SeedMinimalPackDrawTable(SVSimDbContext db)
|
||||
{
|
||||
if (db.PackDrawConfigs.Any())
|
||||
return;
|
||||
|
||||
const int packId = 10001;
|
||||
db.PackDrawConfigs.Add(new PackDrawConfigEntry { Id = packId, AnimationRatePct = 0 });
|
||||
// Slot rates: uniform single-tier so any rng lands somewhere valid.
|
||||
db.PackDrawSlotRates.Add(new PackDrawSlotRateEntry { PackId = packId, Slot = DrawSlot.General, Tier = DrawTier.Bronze, RatePct = 100 });
|
||||
db.PackDrawSlotRates.Add(new PackDrawSlotRateEntry { PackId = packId, Slot = DrawSlot.Eighth, Tier = DrawTier.Bronze, RatePct = 100 });
|
||||
// Card weights for both slots.
|
||||
db.PackDrawCardWeights.Add(new PackDrawCardWeightEntry { PackId = packId, Slot = DrawSlot.General, Tier = DrawTier.Bronze, CardId = 10001001, RatePct = 100 });
|
||||
db.PackDrawCardWeights.Add(new PackDrawCardWeightEntry { PackId = packId, Slot = DrawSlot.Eighth, Tier = DrawTier.Bronze, CardId = 10001001, RatePct = 100 });
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
private static void SeedMinimalCardSet(SVSimDbContext db)
|
||||
{
|
||||
if (db.CardSets.Any())
|
||||
@@ -242,6 +264,59 @@ internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
|
||||
|
||||
await new DefaultDeckImporter().ImportAsync(ctx, seedDir);
|
||||
await new PackImporter().ImportAsync(ctx, seedDir);
|
||||
// PackDrawTableImporter is NOT called here — production draw tables reference real
|
||||
// Cygames card_ids not present in the test's minimal card master. Tests that
|
||||
// exercise /pack/open use SeedPackDrawTableAsync to install a stub draw table
|
||||
// pointing to their seeded test cards.
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Installs a minimal PackDrawConfig + slot rates + per-card weights for <paramref name="packId"/>,
|
||||
/// pointing the per-card weights at <paramref name="cardIds"/>. All cards land in the Bronze tier
|
||||
/// at 100% rate; slot 1-7 and slot 8 both draw from the same pool. Use for tests that need
|
||||
/// /pack/open to succeed against a custom seeded card pool.
|
||||
/// </summary>
|
||||
public Task SeedPackDrawTableAsync(int packId, params long[] cardIds)
|
||||
=> SeedPackDrawTableAsync(packId, DrawTier.Bronze, cardIds);
|
||||
|
||||
/// <summary>
|
||||
/// Convenience for gacha-point tests: picks Legendary cards from <paramref name="cardSetId"/>
|
||||
/// (skipping foils) and seeds them as the draw table's Legendary tier for <paramref name="packId"/>.
|
||||
/// </summary>
|
||||
public async Task SeedPackDrawTableFromSetAsync(int packId, int cardSetId)
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
|
||||
var legendaryIds = await db.CardSets
|
||||
.Where(s => s.Id == cardSetId)
|
||||
.SelectMany(s => s.Cards)
|
||||
.Where(c => c.Rarity == SVSim.Database.Enums.Rarity.Legendary && !c.IsFoil)
|
||||
.Select(c => c.Id)
|
||||
.ToListAsync();
|
||||
|
||||
if (legendaryIds.Count > 0)
|
||||
{
|
||||
await SeedPackDrawTableAsync(packId, DrawTier.Legendary, legendaryIds.ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SeedPackDrawTableAsync(int packId, DrawTier tier, params long[] cardIds)
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
|
||||
if (await db.PackDrawConfigs.AnyAsync(c => c.Id == packId)) return;
|
||||
|
||||
db.PackDrawConfigs.Add(new PackDrawConfigEntry { Id = packId, AnimationRatePct = 0 });
|
||||
db.PackDrawSlotRates.Add(new PackDrawSlotRateEntry { PackId = packId, Slot = DrawSlot.General, Tier = tier, RatePct = 100 });
|
||||
db.PackDrawSlotRates.Add(new PackDrawSlotRateEntry { PackId = packId, Slot = DrawSlot.Eighth, Tier = tier, RatePct = 100 });
|
||||
foreach (var cid in cardIds)
|
||||
{
|
||||
db.PackDrawCardWeights.Add(new PackDrawCardWeightEntry { PackId = packId, Slot = DrawSlot.General, Tier = tier, CardId = cid, RatePct = 100.0 / cardIds.Length });
|
||||
db.PackDrawCardWeights.Add(new PackDrawCardWeightEntry { PackId = packId, Slot = DrawSlot.Eighth, Tier = tier, CardId = cid, RatePct = 100.0 / cardIds.Length });
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
43
SVSim.UnitTests/Repositories/PackDrawTableRepositoryTests.cs
Normal file
43
SVSim.UnitTests/Repositories/PackDrawTableRepositoryTests.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SVSim.Bootstrap.Importers;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Repositories.PackDrawTables;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
|
||||
namespace SVSim.UnitTests.Repositories;
|
||||
|
||||
public class PackDrawTableRepositoryTests
|
||||
{
|
||||
private static string SeedDir => Path.Combine(AppContext.BaseDirectory, "Data", "seeds");
|
||||
|
||||
[Test]
|
||||
public async Task GetAsync_returns_null_when_pack_unseeded()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<IPackDrawTableRepository>();
|
||||
|
||||
var table = await repo.GetAsync(123456);
|
||||
|
||||
Assert.That(table, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAsync_returns_config_slot_rates_and_card_weights_for_seeded_pack()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<IPackDrawTableRepository>();
|
||||
|
||||
await new PackDrawTableImporter().ImportAsync(db, SeedDir);
|
||||
var table = await repo.GetAsync(10000);
|
||||
|
||||
Assert.That(table, Is.Not.Null);
|
||||
Assert.That(table!.Config.AnimationRatePct, Is.EqualTo(8.0));
|
||||
Assert.That(table.SlotRates.Count, Is.GreaterThanOrEqualTo(4)); // bronze/silver/gold/legendary at minimum
|
||||
Assert.That(table.CardWeights.Count, Is.GreaterThan(0));
|
||||
Assert.That(table.CardWeights.All(w => w.PackId == 10000), Is.True);
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,33 @@ public class PackRepositoryTests
|
||||
Assert.That(packs.Select(p => p.Id), Is.EquivalentTo(new[] { 10001 }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetActivePacks_excludes_IsEnabled_false_rows()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
var now = new DateTime(2026, 5, 24, 12, 0, 0, DateTimeKind.Utc);
|
||||
await SeedPack(factory, 10001, 10001, PackCategory.None, now.AddDays(-1), now.AddDays(1));
|
||||
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
db.Packs.Add(new PackConfigEntry
|
||||
{
|
||||
Id = 10002, BasePackId = 10002, PackCategory = PackCategory.None,
|
||||
CommenceDate = now.AddDays(-1), CompleteDate = now.AddDays(1),
|
||||
GachaType = 1, GachaDetail = "disabled",
|
||||
IsEnabled = false,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var scopeRepo = factory.Services.CreateScope();
|
||||
var repo = scopeRepo.ServiceProvider.GetRequiredService<IPackRepository>();
|
||||
var packs = await repo.GetActivePacks(now);
|
||||
|
||||
Assert.That(packs.Select(p => p.Id), Is.EquivalentTo(new[] { 10001 }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetPack_includes_child_gachas_and_banners()
|
||||
{
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.EmulatedEntrypoint.Services;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
|
||||
namespace SVSim.UnitTests.Services;
|
||||
|
||||
public class DbCardPoolProviderTests
|
||||
{
|
||||
[Test]
|
||||
public async Task GetPool_for_standard_pack_returns_cards_of_matching_set()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long anyCardId;
|
||||
int setId;
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var setWithCards = await db.CardSets.Include(s => s.Cards)
|
||||
.FirstAsync(s => s.Cards.Count > 0);
|
||||
setId = setWithCards.Id;
|
||||
anyCardId = setWithCards.Cards.First().Id;
|
||||
}
|
||||
|
||||
using var scope2 = factory.Services.CreateScope();
|
||||
var provider = scope2.ServiceProvider.GetRequiredService<ICardPoolProvider>();
|
||||
var pool = provider.GetPool(new PackConfigEntry
|
||||
{
|
||||
Id = setId, BasePackId = setId, PackCategory = PackCategory.None
|
||||
});
|
||||
|
||||
Assert.That(pool.Any(c => c.Id == anyCardId), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetPool_for_legendary_special_returns_cards_from_rotation_sets()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var provider = scope.ServiceProvider.GetRequiredService<ICardPoolProvider>();
|
||||
|
||||
var pool = provider.GetPool(new PackConfigEntry
|
||||
{
|
||||
Id = 92001, BasePackId = 90001, PackCategory = PackCategory.SpecialCardPack
|
||||
});
|
||||
|
||||
Assert.That(pool.Count, Is.GreaterThan(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetPool_for_skin_pack_returns_empty()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var provider = scope.ServiceProvider.GetRequiredService<ICardPoolProvider>();
|
||||
|
||||
var pool = provider.GetPool(new PackConfigEntry
|
||||
{
|
||||
Id = 70001, BasePackId = 70001, PackCategory = PackCategory.LeaderSkinPack
|
||||
});
|
||||
|
||||
Assert.That(pool, Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetPool_excludes_foil_cards()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long nonFoilId, foilId;
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
// Pick the highest-Id card so that id+1 is guaranteed unoccupied.
|
||||
nonFoilId = await db.Cards.OrderByDescending(c => c.Id).Select(c => c.Id).FirstAsync();
|
||||
foilId = nonFoilId + 1;
|
||||
var foilCard = new ShadowverseCardEntry
|
||||
{
|
||||
Id = foilId, Name = $"Card {foilId}", Rarity = Rarity.Bronze, IsFoil = true,
|
||||
};
|
||||
// Add directly to the Cards DbSet and set the FK via shadow property,
|
||||
// avoiding nav-collection tracker conflicts.
|
||||
db.Cards.Add(foilCard);
|
||||
db.Entry(foilCard).Property("ShadowverseCardSetEntryId").CurrentValue = 10001;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var scope2 = factory.Services.CreateScope();
|
||||
var provider = scope2.ServiceProvider.GetRequiredService<ICardPoolProvider>();
|
||||
var pool = provider.GetPool(new PackConfigEntry
|
||||
{
|
||||
Id = 10001, BasePackId = 10001,
|
||||
PackCategory = PackCategory.None,
|
||||
});
|
||||
|
||||
Assert.That(pool.Any(c => c.Id == nonFoilId), Is.True, "non-foil must be in the pool");
|
||||
Assert.That(pool.Any(c => c.Id == foilId), Is.False, "foil must be excluded from the pool");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task TryGetFoilTwin_returns_the_id_plus_one_foil_when_present()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long nonFoilId, foilId;
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
// Pick the highest-Id card so that id+1 is guaranteed unoccupied.
|
||||
nonFoilId = await db.Cards.OrderByDescending(c => c.Id).Select(c => c.Id).FirstAsync();
|
||||
foilId = nonFoilId + 1;
|
||||
var foilCard = new ShadowverseCardEntry
|
||||
{
|
||||
Id = foilId, Name = $"Card {foilId}", Rarity = Rarity.Bronze, IsFoil = true,
|
||||
};
|
||||
db.Cards.Add(foilCard);
|
||||
db.Entry(foilCard).Property("ShadowverseCardSetEntryId").CurrentValue = 10001;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var scope2 = factory.Services.CreateScope();
|
||||
var provider = scope2.ServiceProvider.GetRequiredService<ICardPoolProvider>();
|
||||
|
||||
var twin = provider.TryGetFoilTwin(nonFoilId);
|
||||
Assert.That(twin, Is.Not.Null);
|
||||
Assert.That(twin!.Id, Is.EqualTo(foilId));
|
||||
Assert.That(twin.IsFoil, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task TryGetFoilTwin_returns_null_when_no_foil_at_id_plus_one()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long anyCardId;
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
anyCardId = await db.Cards.OrderBy(c => c.Id).Select(c => c.Id).FirstAsync();
|
||||
}
|
||||
|
||||
using var scope2 = factory.Services.CreateScope();
|
||||
var provider = scope2.ServiceProvider.GetRequiredService<ICardPoolProvider>();
|
||||
|
||||
Assert.That(provider.TryGetFoilTwin(anyCardId), Is.Null,
|
||||
"no foil seeded at anyCardId+1, so TryGetFoilTwin must return null");
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,7 @@ public class GachaPointServiceTests
|
||||
GachaPointConfig = new PackGachaPointConfig { ExchangeablePoint = 400, IncreaseGachaPoint = 1 },
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
await factory.SeedPackDrawTableFromSetAsync(10008, 10008);
|
||||
|
||||
var svc = scope.ServiceProvider.GetRequiredService<IGachaPointService>();
|
||||
var result = await svc.GetRewardsAsync(10008, viewerId);
|
||||
@@ -125,6 +126,7 @@ public class GachaPointServiceTests
|
||||
GachaPointConfig = new PackGachaPointConfig { ExchangeablePoint = 400, IncreaseGachaPoint = 1 },
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
await factory.SeedPackDrawTableFromSetAsync(10008, 10008);
|
||||
|
||||
var svc = scope.ServiceProvider.GetRequiredService<IGachaPointService>();
|
||||
var result = await svc.GetRewardsAsync(10008, viewerId);
|
||||
@@ -173,6 +175,7 @@ public class GachaPointServiceTests
|
||||
GachaPointConfig = new PackGachaPointConfig { ExchangeablePoint = 400, IncreaseGachaPoint = 1 },
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
await factory.SeedPackDrawTableFromSetAsync(10008, 10008);
|
||||
|
||||
var svc = scope.ServiceProvider.GetRequiredService<IGachaPointService>();
|
||||
var result = await svc.GetRewardsAsync(10008, viewerId);
|
||||
@@ -219,6 +222,7 @@ public class GachaPointServiceTests
|
||||
PackId = 10008, CardId = 108041010, ReceivedAt = DateTime.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
await factory.SeedPackDrawTableFromSetAsync(10008, 10008);
|
||||
|
||||
var svc = scope.ServiceProvider.GetRequiredService<IGachaPointService>();
|
||||
var result = await svc.GetRewardsAsync(10008, viewerId);
|
||||
@@ -515,6 +519,7 @@ public class GachaPointServiceTests
|
||||
GachaPointConfig = new PackGachaPointConfig { ExchangeablePoint = 400, IncreaseGachaPoint = 1 },
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
await factory.SeedPackDrawTableFromSetAsync(10008, 10008);
|
||||
|
||||
var svc = scope.ServiceProvider.GetRequiredService<IGachaPointService>();
|
||||
var result = await svc.GetRewardsAsync(10008, viewerId);
|
||||
@@ -555,6 +560,7 @@ public class GachaPointServiceTests
|
||||
GachaPointConfig = new PackGachaPointConfig { ExchangeablePoint = 400, IncreaseGachaPoint = 1 },
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
await factory.SeedPackDrawTableFromSetAsync(10099, 10099);
|
||||
|
||||
var svc = scope.ServiceProvider.GetRequiredService<IGachaPointService>();
|
||||
var result = await svc.GetRewardsAsync(10099, viewerId);
|
||||
@@ -586,6 +592,14 @@ public class GachaPointServiceTests
|
||||
CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30),
|
||||
GachaPointConfig = new PackGachaPointConfig { ExchangeablePoint = threshold, IncreaseGachaPoint = 1 },
|
||||
});
|
||||
// Draw table pointing at the seeded legendary so IPackDrawTableRepository.GetAsync
|
||||
// surfaces it for GachaPointService.GetRewardsAsync / TryExchangeAsync.
|
||||
db.PackDrawConfigs.Add(new PackDrawConfigEntry { Id = packId, AnimationRatePct = 0 });
|
||||
db.PackDrawSlotRates.Add(new PackDrawSlotRateEntry { PackId = packId, Slot = DrawSlot.General, Tier = DrawTier.Legendary, RatePct = 100 });
|
||||
db.PackDrawCardWeights.Add(new PackDrawCardWeightEntry
|
||||
{
|
||||
PackId = packId, Slot = DrawSlot.General, Tier = DrawTier.Legendary, CardId = 108041010, RatePct = 100,
|
||||
});
|
||||
db.SaveChanges();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Models.Config;
|
||||
using SVSim.Database.Repositories.PackDrawTables;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
@@ -8,272 +8,219 @@ namespace SVSim.UnitTests.Services;
|
||||
|
||||
public class PackOpenServiceTests
|
||||
{
|
||||
/// <summary>Deterministic RNG that returns the supplied doubles in order, cycling.</summary>
|
||||
private sealed class ScriptedRandom : IRandom
|
||||
{
|
||||
private readonly double[] _seq; private int _i;
|
||||
public ScriptedRandom(params double[] seq) { _seq = seq; }
|
||||
public double NextDouble() { var v = _seq[_i++ % _seq.Length]; return v; }
|
||||
public double NextDouble() => _seq[_i++ % _seq.Length];
|
||||
public int Next(int maxExclusive) => (int)(NextDouble() * maxExclusive);
|
||||
}
|
||||
|
||||
/// <summary>Simple in-memory pool keyed by rarity for slot-distribution tests.</summary>
|
||||
private sealed class StubPool : ICardPoolProvider
|
||||
private sealed class NoFoil : ICardFoilLookup
|
||||
{
|
||||
private readonly IReadOnlyList<ShadowverseCardEntry> _cards;
|
||||
public StubPool(IReadOnlyList<ShadowverseCardEntry> cards) { _cards = cards; }
|
||||
public IReadOnlyList<ShadowverseCardEntry> GetPool(PackConfigEntry _) => _cards;
|
||||
public ShadowverseCardEntry? TryGetFoilTwin(long baseCardId) => null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test stub that returns a single pre-built section. Only handles <see cref="PackRateConfig"/>
|
||||
/// (the type <see cref="PackOpenService"/> reads in its ctor); other section types throw so a
|
||||
/// future test that needs them must extend this stub explicitly.
|
||||
/// </summary>
|
||||
private sealed class StubConfig : IGameConfigService
|
||||
private static PackConfigEntry StandardPack(int id = 10000) => new()
|
||||
{
|
||||
private readonly PackRateConfig _rates;
|
||||
public StubConfig(PackRateConfig rates) { _rates = rates; }
|
||||
public T Get<T>() where T : class, new()
|
||||
{
|
||||
if (typeof(T) == typeof(PackRateConfig)) return (T)(object)_rates;
|
||||
throw new NotImplementedException($"StubConfig: unhandled section type {typeof(T)}.");
|
||||
}
|
||||
}
|
||||
|
||||
private static PackOpenService MakeService(PackRateConfig rates) => new(new StubConfig(rates));
|
||||
|
||||
private static List<ShadowverseCardEntry> MakeFourCards() => new()
|
||||
{
|
||||
new ShadowverseCardEntry { Id = 1, Rarity = Rarity.Bronze },
|
||||
new ShadowverseCardEntry { Id = 2, Rarity = Rarity.Silver },
|
||||
new ShadowverseCardEntry { Id = 3, Rarity = Rarity.Gold },
|
||||
new ShadowverseCardEntry { Id = 4, Rarity = Rarity.Legendary },
|
||||
Id = id, BasePackId = id, PackCategory = PackCategory.None,
|
||||
};
|
||||
|
||||
private static PackConfigEntry StandardPack() => new()
|
||||
private static PackDrawTable AllBronzeTable() => new()
|
||||
{
|
||||
Id = 10001, BasePackId = 10001, PackCategory = PackCategory.None,
|
||||
Config = new PackDrawConfigEntry { Id = 10000, AnimationRatePct = 0 },
|
||||
SlotRates = new[]
|
||||
{
|
||||
new PackDrawSlotRateEntry { PackId = 10000, Slot = DrawSlot.General, Tier = DrawTier.Bronze, RatePct = 100.0 },
|
||||
new PackDrawSlotRateEntry { PackId = 10000, Slot = DrawSlot.Eighth, Tier = DrawTier.Bronze, RatePct = 100.0 },
|
||||
},
|
||||
CardWeights = new[]
|
||||
{
|
||||
new PackDrawCardWeightEntry { PackId = 10000, Slot = DrawSlot.General, Tier = DrawTier.Bronze, CardId = 1, RatePct = 70 },
|
||||
new PackDrawCardWeightEntry { PackId = 10000, Slot = DrawSlot.General, Tier = DrawTier.Bronze, CardId = 2, RatePct = 30 },
|
||||
new PackDrawCardWeightEntry { PackId = 10000, Slot = DrawSlot.Eighth, Tier = DrawTier.Bronze, CardId = 1, RatePct = 100 },
|
||||
},
|
||||
};
|
||||
|
||||
[Test]
|
||||
public void Draw_returns_eight_cards_for_one_pack()
|
||||
{
|
||||
var svc = MakeService(PackRateConfig.ShippedDefaults());
|
||||
var pool = new StubPool(MakeFourCards());
|
||||
var rng = new ScriptedRandom(0.5); // anything in Bronze/Silver range for non-slot-8
|
||||
var svc = new PackOpenService();
|
||||
var rng = new ScriptedRandom(0.1);
|
||||
|
||||
var result = svc.Draw(StandardPack(), pool, packNumber: 1, excludeCardIds: Array.Empty<long>(), rng: rng);
|
||||
var result = svc.Draw(AllBronzeTable(), StandardPack(), 1,
|
||||
excludeCardIds: Array.Empty<long>(), ownedCardIds: Array.Empty<long>(),
|
||||
new NoFoil(), rng);
|
||||
|
||||
Assert.That(result.Cards.Count, Is.EqualTo(8));
|
||||
Assert.That(result.Cards.All(c => c.CardId == 1), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Draw_picks_card_by_per_card_weight_within_tier()
|
||||
{
|
||||
var svc = new PackOpenService();
|
||||
// Tier roll always lands in Bronze (only tier). Card pick rng=0.8 -> within Bronze
|
||||
// band > 0.7 -> card 2. Slot 8 has only card 1 in its pool so it always picks card 1.
|
||||
var rng = new ScriptedRandom(0.0, 0.8);
|
||||
|
||||
var result = svc.Draw(AllBronzeTable(), StandardPack(), 1,
|
||||
Array.Empty<long>(), Array.Empty<long>(), new NoFoil(), rng);
|
||||
|
||||
Assert.That(result.Cards.Take(7).All(c => c.CardId == 2), Is.True, "slots 1-7 should pick card 2");
|
||||
Assert.That(result.Cards[7].CardId, Is.EqualTo(1), "slot 8 pool only contains card 1");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Draw_rate_less_branch_picks_only_unowned()
|
||||
{
|
||||
var pack = new PackConfigEntry { Id = 98001, BasePackId = 98001, PackCategory = PackCategory.SpecialCardPack };
|
||||
var table = new PackDrawTable
|
||||
{
|
||||
Config = new PackDrawConfigEntry { Id = 98001, AnimationRatePct = 0, HasBonusSlot = true, SpecialKind = "leader_card" },
|
||||
SlotRates = new[]
|
||||
{
|
||||
new PackDrawSlotRateEntry { PackId = 98001, Slot = DrawSlot.General, Tier = DrawTier.Bronze, RatePct = 100.0 },
|
||||
new PackDrawSlotRateEntry { PackId = 98001, Slot = DrawSlot.Eighth, Tier = DrawTier.Bronze, RatePct = 100.0 },
|
||||
new PackDrawSlotRateEntry { PackId = 98001, Slot = DrawSlot.Bonus, Tier = DrawTier.Special, RatePct = 100.0 },
|
||||
},
|
||||
CardWeights = new[]
|
||||
{
|
||||
new PackDrawCardWeightEntry { PackId = 98001, Slot = DrawSlot.General, Tier = DrawTier.Bronze, CardId = 10, RatePct = 100 },
|
||||
new PackDrawCardWeightEntry { PackId = 98001, Slot = DrawSlot.Eighth, Tier = DrawTier.Bronze, CardId = 10, RatePct = 100 },
|
||||
new PackDrawCardWeightEntry { PackId = 98001, Slot = DrawSlot.Bonus, Tier = DrawTier.Special, CardId = 300, RatePct = null, IsLeader = true },
|
||||
new PackDrawCardWeightEntry { PackId = 98001, Slot = DrawSlot.Bonus, Tier = DrawTier.Special, CardId = 301, RatePct = null, IsLeader = true },
|
||||
new PackDrawCardWeightEntry { PackId = 98001, Slot = DrawSlot.Bonus, Tier = DrawTier.Special, CardId = 302, RatePct = null, IsLeader = true },
|
||||
},
|
||||
};
|
||||
var svc = new PackOpenService();
|
||||
var rng = new ScriptedRandom(0.1);
|
||||
|
||||
var result = svc.Draw(table, pack, packNumber: 10,
|
||||
excludeCardIds: Array.Empty<long>(),
|
||||
ownedCardIds: new long[] { 300, 301 },
|
||||
new NoFoil(), rng);
|
||||
|
||||
Assert.That(result.Cards.Count, Is.EqualTo(81)); // 10 packs * 8 + 1 bonus
|
||||
var bonus = result.Cards[^1];
|
||||
Assert.That(bonus.CardId, Is.EqualTo(302));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Draw_rate_less_falls_back_to_full_pool_when_all_owned()
|
||||
{
|
||||
var pack = new PackConfigEntry { Id = 98001, BasePackId = 98001 };
|
||||
var table = new PackDrawTable
|
||||
{
|
||||
Config = new PackDrawConfigEntry { Id = 98001, AnimationRatePct = 0, HasBonusSlot = true },
|
||||
SlotRates = new[]
|
||||
{
|
||||
new PackDrawSlotRateEntry { PackId = 98001, Slot = DrawSlot.General, Tier = DrawTier.Bronze, RatePct = 100.0 },
|
||||
new PackDrawSlotRateEntry { PackId = 98001, Slot = DrawSlot.Eighth, Tier = DrawTier.Bronze, RatePct = 100.0 },
|
||||
new PackDrawSlotRateEntry { PackId = 98001, Slot = DrawSlot.Bonus, Tier = DrawTier.Special, RatePct = 100.0 },
|
||||
},
|
||||
CardWeights = new[]
|
||||
{
|
||||
new PackDrawCardWeightEntry { PackId = 98001, Slot = DrawSlot.General, Tier = DrawTier.Bronze, CardId = 10, RatePct = 100 },
|
||||
new PackDrawCardWeightEntry { PackId = 98001, Slot = DrawSlot.Eighth, Tier = DrawTier.Bronze, CardId = 10, RatePct = 100 },
|
||||
new PackDrawCardWeightEntry { PackId = 98001, Slot = DrawSlot.Bonus, Tier = DrawTier.Special, CardId = 300, RatePct = null, IsLeader = true },
|
||||
new PackDrawCardWeightEntry { PackId = 98001, Slot = DrawSlot.Bonus, Tier = DrawTier.Special, CardId = 301, RatePct = null, IsLeader = true },
|
||||
},
|
||||
};
|
||||
var svc = new PackOpenService();
|
||||
var rng = new ScriptedRandom(0.1);
|
||||
|
||||
var result = svc.Draw(table, pack, packNumber: 10,
|
||||
excludeCardIds: Array.Empty<long>(),
|
||||
ownedCardIds: new long[] { 300, 301 },
|
||||
new NoFoil(), rng);
|
||||
|
||||
var bonus = result.Cards[^1];
|
||||
Assert.That(bonus.CardId, Is.AnyOf(300L, 301L));
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Category("Slow")]
|
||||
public void Draw_observed_tier_rates_track_seed_within_half_a_percent()
|
||||
{
|
||||
// Synthetic Classic pack: Bronze=76.5/Silver=16/Gold=6/Legendary=1.5 in general slots;
|
||||
// slot 8 is Silver=92.5/Gold=6/Legendary=1.5 (no Bronze).
|
||||
var table = new PackDrawTable
|
||||
{
|
||||
Config = new PackDrawConfigEntry { Id = 10000, AnimationRatePct = 0 },
|
||||
SlotRates = new[]
|
||||
{
|
||||
new PackDrawSlotRateEntry { PackId = 10000, Slot = DrawSlot.General, Tier = DrawTier.Bronze, RatePct = 76.5 },
|
||||
new PackDrawSlotRateEntry { PackId = 10000, Slot = DrawSlot.General, Tier = DrawTier.Silver, RatePct = 16.0 },
|
||||
new PackDrawSlotRateEntry { PackId = 10000, Slot = DrawSlot.General, Tier = DrawTier.Gold, RatePct = 6.0 },
|
||||
new PackDrawSlotRateEntry { PackId = 10000, Slot = DrawSlot.General, Tier = DrawTier.Legendary, RatePct = 1.5 },
|
||||
new PackDrawSlotRateEntry { PackId = 10000, Slot = DrawSlot.Eighth, Tier = DrawTier.Silver, RatePct = 92.5 },
|
||||
new PackDrawSlotRateEntry { PackId = 10000, Slot = DrawSlot.Eighth, Tier = DrawTier.Gold, RatePct = 6.0 },
|
||||
new PackDrawSlotRateEntry { PackId = 10000, Slot = DrawSlot.Eighth, Tier = DrawTier.Legendary, RatePct = 1.5 },
|
||||
},
|
||||
CardWeights = new[]
|
||||
{
|
||||
new PackDrawCardWeightEntry { PackId = 10000, Slot = DrawSlot.General, Tier = DrawTier.Bronze, CardId = 1, RatePct = 76.5 },
|
||||
new PackDrawCardWeightEntry { PackId = 10000, Slot = DrawSlot.General, Tier = DrawTier.Silver, CardId = 2, RatePct = 16.0 },
|
||||
new PackDrawCardWeightEntry { PackId = 10000, Slot = DrawSlot.General, Tier = DrawTier.Gold, CardId = 3, RatePct = 6.0 },
|
||||
new PackDrawCardWeightEntry { PackId = 10000, Slot = DrawSlot.General, Tier = DrawTier.Legendary, CardId = 4, RatePct = 1.5 },
|
||||
},
|
||||
};
|
||||
var svc = new PackOpenService();
|
||||
var rng = new SystemRandom(42);
|
||||
var pack = new PackConfigEntry { Id = 10000 };
|
||||
int totalSlots = 200_000;
|
||||
int bronze = 0, silver = 0, gold = 0, legendary = 0;
|
||||
|
||||
// 25_000 packs * 7 general slots = 175_000 general-slot observations.
|
||||
for (int i = 0; i < totalSlots / 8; i++)
|
||||
{
|
||||
var r = svc.Draw(table, pack, 1, Array.Empty<long>(), Array.Empty<long>(), new NoFoil(), rng);
|
||||
for (int s = 0; s < 7; s++)
|
||||
{
|
||||
switch (r.Cards[s].Rarity)
|
||||
{
|
||||
case Rarity.Bronze: bronze++; break;
|
||||
case Rarity.Silver: silver++; break;
|
||||
case Rarity.Gold: gold++; break;
|
||||
case Rarity.Legendary: legendary++; break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
double n = bronze + silver + gold + legendary;
|
||||
Assert.That(100 * bronze / n, Is.EqualTo(76.5).Within(0.5));
|
||||
Assert.That(100 * silver / n, Is.EqualTo(16.0).Within(0.5));
|
||||
Assert.That(100 * gold / n, Is.EqualTo(6.0).Within(0.5));
|
||||
Assert.That(100 * legendary / n, Is.EqualTo(1.5).Within(0.5));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Draw_does_not_emit_bonus_for_packNumber_less_than_10()
|
||||
{
|
||||
var pack = new PackConfigEntry { Id = 98001 };
|
||||
var table = new PackDrawTable
|
||||
{
|
||||
Config = new PackDrawConfigEntry { Id = 98001, HasBonusSlot = true, AnimationRatePct = 0 },
|
||||
SlotRates = new[]
|
||||
{
|
||||
new PackDrawSlotRateEntry { PackId = 98001, Slot = DrawSlot.General, Tier = DrawTier.Bronze, RatePct = 100 },
|
||||
new PackDrawSlotRateEntry { PackId = 98001, Slot = DrawSlot.Eighth, Tier = DrawTier.Bronze, RatePct = 100 },
|
||||
new PackDrawSlotRateEntry { PackId = 98001, Slot = DrawSlot.Bonus, Tier = DrawTier.Special, RatePct = 100 },
|
||||
},
|
||||
CardWeights = new[]
|
||||
{
|
||||
new PackDrawCardWeightEntry { PackId = 98001, Slot = DrawSlot.General, Tier = DrawTier.Bronze, CardId = 1, RatePct = 100 },
|
||||
new PackDrawCardWeightEntry { PackId = 98001, Slot = DrawSlot.Eighth, Tier = DrawTier.Bronze, CardId = 1, RatePct = 100 },
|
||||
new PackDrawCardWeightEntry { PackId = 98001, Slot = DrawSlot.Bonus, Tier = DrawTier.Special, CardId = 999, RatePct = null, IsLeader = true },
|
||||
},
|
||||
};
|
||||
var svc = new PackOpenService();
|
||||
var rng = new ScriptedRandom(0.1);
|
||||
|
||||
var result = svc.Draw(table, pack, packNumber: 1,
|
||||
Array.Empty<long>(), Array.Empty<long>(), new NoFoil(), rng);
|
||||
|
||||
Assert.That(result.Cards.Count, Is.EqualTo(8));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Draw_slot_8_never_returns_bronze_for_standard_pack()
|
||||
{
|
||||
// PackRateConfig.ShippedDefaults() includes the SV Classic slot-8 "Silver-or-better
|
||||
// guarantee" entry (PerSlot Bronze=0). Same shape the runtime seeder writes to GameConfigs.
|
||||
var svc = MakeService(PackRateConfig.ShippedDefaults());
|
||||
var pool = new StubPool(MakeFourCards());
|
||||
|
||||
for (int trial = 0; trial < 1000; trial++)
|
||||
{
|
||||
var result = svc.Draw(StandardPack(), pool, 1, Array.Empty<long>(), new SystemRandom(trial));
|
||||
Assert.That(result.Cards[7].Rarity, Is.Not.EqualTo(Rarity.Bronze),
|
||||
$"slot 8 must never be Bronze (trial {trial})");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Draw_legendary_special_forces_slot_8_to_legendary()
|
||||
{
|
||||
var svc = MakeService(PackRateConfig.ShippedDefaults());
|
||||
var pool = new StubPool(MakeFourCards());
|
||||
var pack = new PackConfigEntry { Id = 92001, BasePackId = 90001, PackCategory = PackCategory.SpecialCardPack };
|
||||
|
||||
for (int trial = 0; trial < 100; trial++)
|
||||
{
|
||||
var result = svc.Draw(pack, pool, 1, Array.Empty<long>(), new SystemRandom(trial));
|
||||
Assert.That(result.Cards[7].Rarity, Is.EqualTo(Rarity.Legendary),
|
||||
$"legendary-special pack slot 8 must be Legendary (trial {trial})");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Draw_distribution_over_10k_slots_1_to_7_matches_declared_rates_within_2_percent()
|
||||
{
|
||||
var svc = MakeService(PackRateConfig.ShippedDefaults());
|
||||
var pool = new StubPool(MakeFourCards());
|
||||
var counts = new Dictionary<Rarity, int>
|
||||
{
|
||||
{ Rarity.Bronze, 0 }, { Rarity.Silver, 0 }, { Rarity.Gold, 0 }, { Rarity.Legendary, 0 }
|
||||
};
|
||||
|
||||
var rng = new SystemRandom(seed: 42);
|
||||
const int packs = 10_000;
|
||||
for (int i = 0; i < packs; i++)
|
||||
{
|
||||
var r = svc.Draw(StandardPack(), pool, 1, Array.Empty<long>(), rng);
|
||||
// Only look at slots 0..6 (the unrestricted rarity slots)
|
||||
for (int s = 0; s < 7; s++) counts[r.Cards[s].Rarity]++;
|
||||
}
|
||||
|
||||
int total = packs * 7;
|
||||
double bronze = counts[Rarity.Bronze] / (double)total;
|
||||
double silver = counts[Rarity.Silver] / (double)total;
|
||||
double gold = counts[Rarity.Gold] / (double)total;
|
||||
double leg = counts[Rarity.Legendary] / (double)total;
|
||||
|
||||
Assert.That(bronze, Is.EqualTo(0.6744).Within(0.02), $"bronze rate {bronze:P}");
|
||||
Assert.That(silver, Is.EqualTo(0.2500).Within(0.02), $"silver rate {silver:P}");
|
||||
Assert.That(gold, Is.EqualTo(0.0600).Within(0.01), $"gold rate {gold:P}");
|
||||
Assert.That(leg, Is.EqualTo(0.0150).Within(0.01), $"legendary rate {leg:P}");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Draw_excludes_listed_card_ids()
|
||||
{
|
||||
var svc = MakeService(PackRateConfig.ShippedDefaults());
|
||||
// Pool with two bronze cards; exclude one — every Bronze slot must pick the other.
|
||||
var pool = new StubPool(new List<ShadowverseCardEntry>
|
||||
{
|
||||
new() { Id = 1, Rarity = Rarity.Bronze },
|
||||
new() { Id = 99, Rarity = Rarity.Bronze },
|
||||
new() { Id = 2, Rarity = Rarity.Silver },
|
||||
});
|
||||
|
||||
var rng = new SystemRandom(seed: 7);
|
||||
var result = svc.Draw(StandardPack(), pool, 1, excludeCardIds: new long[] { 1 }, rng: rng);
|
||||
|
||||
foreach (var c in result.Cards.Where(x => x.Rarity == Rarity.Bronze))
|
||||
{
|
||||
Assert.That(c.CardId, Is.EqualTo(99), "excluded card 1 must never appear in Bronze slot");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Draw_per_slot_override_is_applied_for_that_slot_and_default_for_others()
|
||||
{
|
||||
// Config: slot 3 is forced to Legendary; everything else uses Default.
|
||||
// PerSlot is a List<SlotRarityWeights> with a Slot string key (no Dictionary<int,T> of
|
||||
// complex types under jsonb-friendly serialisation — see Task 5 notes).
|
||||
var rates = PackRateConfig.ShippedDefaults();
|
||||
rates.PerSlot.Add(new SlotRarityWeights
|
||||
{
|
||||
Slot = "3",
|
||||
Bronze = 0, Silver = 0, Gold = 0, Legendary = 1.0,
|
||||
});
|
||||
|
||||
var svc = MakeService(rates);
|
||||
var pool = new StubPool(MakeFourCards());
|
||||
|
||||
for (int trial = 0; trial < 50; trial++)
|
||||
{
|
||||
var result = svc.Draw(StandardPack(), pool, 1, Array.Empty<long>(), new SystemRandom(trial));
|
||||
Assert.That(result.Cards[2].Rarity, Is.EqualTo(Rarity.Legendary),
|
||||
$"slot 3 must be Legendary under PerSlot[3] override (trial {trial})");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>StubPool variant that also implements TryGetFoilTwin via the SAME id+1 convention
|
||||
/// as the DB-backed provider, but keyed off an injected dictionary so tests stay hermetic.</summary>
|
||||
private sealed class StubPoolWithFoils : ICardPoolProvider
|
||||
{
|
||||
private readonly IReadOnlyList<ShadowverseCardEntry> _pool;
|
||||
private readonly Dictionary<long, ShadowverseCardEntry> _foilsByBaseId;
|
||||
public StubPoolWithFoils(IReadOnlyList<ShadowverseCardEntry> pool, Dictionary<long, ShadowverseCardEntry> foilsByBaseId)
|
||||
{
|
||||
_pool = pool;
|
||||
_foilsByBaseId = foilsByBaseId;
|
||||
}
|
||||
public IReadOnlyList<ShadowverseCardEntry> GetPool(PackConfigEntry _) => _pool;
|
||||
public ShadowverseCardEntry? TryGetFoilTwin(long baseCardId) =>
|
||||
_foilsByBaseId.TryGetValue(baseCardId, out var f) ? f : null;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Draw_animated_rate_upgrades_about_8_percent_of_slots_within_tolerance()
|
||||
{
|
||||
// One bronze card with a foil twin; rate = 0.08; ~8% of 8000 slots should be foil.
|
||||
var bronze = new ShadowverseCardEntry { Id = 1, Rarity = Rarity.Bronze, IsFoil = false };
|
||||
var bronzeFoil = new ShadowverseCardEntry { Id = 2, Rarity = Rarity.Bronze, IsFoil = true };
|
||||
var pools = new StubPoolWithFoils(
|
||||
new List<ShadowverseCardEntry> { bronze },
|
||||
new Dictionary<long, ShadowverseCardEntry> { [bronze.Id] = bronzeFoil });
|
||||
|
||||
var svc = MakeService(PackRateConfig.ShippedDefaults()); // default AnimatedRate = 0.08
|
||||
|
||||
const int packs = 1_000; // 8000 slots
|
||||
int foilCount = 0;
|
||||
var rng = new SystemRandom(seed: 7);
|
||||
for (int i = 0; i < packs; i++)
|
||||
{
|
||||
var r = svc.Draw(StandardPack(), pools, 1, Array.Empty<long>(), rng);
|
||||
foilCount += r.Cards.Count(c => c.CardId == bronzeFoil.Id);
|
||||
}
|
||||
double rate = foilCount / (double)(packs * 8);
|
||||
Assert.That(rate, Is.EqualTo(0.08).Within(0.015),
|
||||
$"observed animated rate {rate:P} outside the ±1.5% tolerance of 8%");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Draw_animated_upgrade_silently_keeps_base_when_no_foil_twin_exists()
|
||||
{
|
||||
var bronze = new ShadowverseCardEntry { Id = 1, Rarity = Rarity.Bronze, IsFoil = false };
|
||||
var pools = new StubPoolWithFoils(
|
||||
new List<ShadowverseCardEntry> { bronze },
|
||||
new Dictionary<long, ShadowverseCardEntry>()); // no foils
|
||||
|
||||
// Force the animated roll to always hit by setting AnimatedRate = 1.0.
|
||||
var rates = PackRateConfig.ShippedDefaults();
|
||||
rates.AnimatedRate = 1.0;
|
||||
var svc = MakeService(rates);
|
||||
|
||||
var r = svc.Draw(StandardPack(), pools, 1, Array.Empty<long>(), new SystemRandom(seed: 1));
|
||||
foreach (var c in r.Cards)
|
||||
{
|
||||
Assert.That(c.CardId, Is.EqualTo(bronze.Id),
|
||||
"no foil twin available; every slot must keep the base card despite 100% animated rate");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Draw_animated_upgrade_applies_to_slot_8_including_legendary_specials()
|
||||
{
|
||||
var leg = new ShadowverseCardEntry { Id = 4, Rarity = Rarity.Legendary, IsFoil = false };
|
||||
var legFoil = new ShadowverseCardEntry { Id = 5, Rarity = Rarity.Legendary, IsFoil = true };
|
||||
var pools = new StubPoolWithFoils(
|
||||
new List<ShadowverseCardEntry> { leg },
|
||||
new Dictionary<long, ShadowverseCardEntry> { [leg.Id] = legFoil });
|
||||
|
||||
var rates = PackRateConfig.ShippedDefaults();
|
||||
rates.AnimatedRate = 1.0; // every slot upgrades
|
||||
var svc = MakeService(rates);
|
||||
|
||||
var specialPack = new PackConfigEntry { Id = 92001, BasePackId = 90001, PackCategory = PackCategory.SpecialCardPack };
|
||||
var r = svc.Draw(specialPack, pools, 1, Array.Empty<long>(), new SystemRandom(seed: 3));
|
||||
|
||||
// Slot 8 is forced Legendary by the structural rule; with AnimatedRate=1.0 it must be the foil legendary.
|
||||
Assert.That(r.Cards[7].CardId, Is.EqualTo(legFoil.Id),
|
||||
"legendary-special slot 8 must be the foil-legendary when animated rate is forced to 1.0");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression guard for the 2026-05-24 slot-8-ignores-PerSlot-override bug. Invariant: a
|
||||
/// freshly-constructed <see cref="PackRateConfig"/> has an EMPTY PerSlot list. The original
|
||||
/// trigger (EF Core 8's <c>OwnsMany</c>+<c>ToJson</c> jsonb materialisation appending rows
|
||||
/// onto whatever the parent's ctor produced — leaving two slot-8 entries where the seeded one
|
||||
/// silently won <see cref="PackOpenService.ResolveWeights"/>'s <c>FirstOrDefault</c>) is gone
|
||||
/// now (config goes through <c>IGameConfigService</c> + STJ, which replaces correctly). The
|
||||
/// invariant stays because any future config layer that hydrates into a pre-initialised
|
||||
/// collection (custom deserialiser, ORM, manual Add loop) would resurrect the same failure
|
||||
/// mode. Defaults for collections live in <see cref="PackRateConfig.ShippedDefaults"/>.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void PackRateConfig_PerSlot_defaults_to_empty_to_avoid_jsonb_append_bug()
|
||||
{
|
||||
Assert.That(new PackRateConfig().PerSlot, Is.Empty,
|
||||
"PackRateConfig.PerSlot must default to empty — see test docstring for why.");
|
||||
}
|
||||
}
|
||||
|
||||
63
SVSim.UnitTests/Services/WeightedPickTests.cs
Normal file
63
SVSim.UnitTests/Services/WeightedPickTests.cs
Normal file
@@ -0,0 +1,63 @@
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
namespace SVSim.UnitTests.Services;
|
||||
|
||||
public class WeightedPickTests
|
||||
{
|
||||
private sealed class ScriptedRandom : IRandom
|
||||
{
|
||||
private readonly double[] _seq; private int _i;
|
||||
public ScriptedRandom(params double[] seq) { _seq = seq; }
|
||||
public double NextDouble() => _seq[_i++ % _seq.Length];
|
||||
public int Next(int maxExclusive) => (int)(NextDouble() * maxExclusive);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Picks_first_band_when_rng_low()
|
||||
{
|
||||
var items = new[] { "a", "b", "c" };
|
||||
var weights = new[] { 0.5, 0.3, 0.2 };
|
||||
var rng = new ScriptedRandom(0.1);
|
||||
|
||||
var picked = WeightedPick.Pick(rng, items, weights);
|
||||
|
||||
Assert.That(picked, Is.EqualTo("a"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Picks_middle_band()
|
||||
{
|
||||
var items = new[] { "a", "b", "c" };
|
||||
var weights = new[] { 0.5, 0.3, 0.2 };
|
||||
var rng = new ScriptedRandom(0.7);
|
||||
|
||||
var picked = WeightedPick.Pick(rng, items, weights);
|
||||
|
||||
Assert.That(picked, Is.EqualTo("b"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Renormalizes_when_weights_dont_sum_to_one()
|
||||
{
|
||||
var items = new[] { "a", "b" };
|
||||
var weights = new[] { 50.0, 50.0 };
|
||||
var rng = new ScriptedRandom(0.4);
|
||||
|
||||
var picked = WeightedPick.Pick(rng, items, weights);
|
||||
|
||||
Assert.That(picked, Is.EqualTo("a"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Falls_through_to_last_item_when_rng_exceeds_sum_minus_epsilon()
|
||||
{
|
||||
var items = new[] { "a", "b" };
|
||||
var weights = new[] { 0.5, 0.5 };
|
||||
var rng = new ScriptedRandom(0.999);
|
||||
|
||||
var picked = WeightedPick.Pick(rng, items, weights);
|
||||
|
||||
Assert.That(picked, Is.EqualTo("b"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user