Pack logic cleanup
This commit is contained in:
@@ -84,6 +84,8 @@ public class CardImporter
|
|||||||
DustReward = ParseInt(c.GetRedEther, 0)
|
DustReward = ParseInt(c.GetRedEther, 0)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
bool isFoil = c.IsFoil == "1";
|
||||||
|
|
||||||
if (existingCards.TryGetValue(id, out var card))
|
if (existingCards.TryGetValue(id, out var card))
|
||||||
{
|
{
|
||||||
card.Rarity = (Rarity)rarity;
|
card.Rarity = (Rarity)rarity;
|
||||||
@@ -92,6 +94,7 @@ public class CardImporter
|
|||||||
card.Defense = ParseNullableInt(c.Life);
|
card.Defense = ParseNullableInt(c.Life);
|
||||||
card.Class = classEntry;
|
card.Class = classEntry;
|
||||||
card.CollectionInfo = collection;
|
card.CollectionInfo = collection;
|
||||||
|
card.IsFoil = isFoil;
|
||||||
updated++;
|
updated++;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -105,7 +108,8 @@ public class CardImporter
|
|||||||
Attack = ParseNullableInt(c.Atk),
|
Attack = ParseNullableInt(c.Atk),
|
||||||
Defense = ParseNullableInt(c.Life),
|
Defense = ParseNullableInt(c.Life),
|
||||||
Class = classEntry,
|
Class = classEntry,
|
||||||
CollectionInfo = collection
|
CollectionInfo = collection,
|
||||||
|
IsFoil = isFoil
|
||||||
};
|
};
|
||||||
set.Cards.Add(card);
|
set.Cards.Add(card);
|
||||||
existingCards[id] = card;
|
existingCards[id] = card;
|
||||||
@@ -145,4 +149,5 @@ public class CardInput
|
|||||||
public string? Rarity { get; set; }
|
public string? Rarity { get; set; }
|
||||||
public string? GetRedEther { get; set; }
|
public string? GetRedEther { get; set; }
|
||||||
public string? UseRedEther { get; set; }
|
public string? UseRedEther { get; set; }
|
||||||
|
public string? IsFoil { get; set; } // cards.json `is_foil` = "0" or "1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,18 +99,19 @@ public class GlobalsImporter
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg.TsRotationId = GetString(loadIndex, "ts_rotation_id");
|
// TODO: fixed in Task 6 — writes through Config tree after RefactorGameConfigurationToJsonb
|
||||||
cfg.IsBattlePassPeriod = GetBool(loadIndex, "is_battle_pass_period");
|
cfg.Config.Rotation.TsRotationId = GetString(loadIndex, "ts_rotation_id");
|
||||||
cfg.IsBeginnerMission = GetBool(loadIndex, "is_beginner_mission");
|
cfg.Config.Rotation.IsBattlePassPeriod = GetBool(loadIndex, "is_battle_pass_period");
|
||||||
cfg.CardSetIdForResourceDlView = GetInt(loadIndex, "card_set_id_for_resource_dl_view");
|
cfg.Config.Rotation.IsBeginnerMission = GetBool(loadIndex, "is_beginner_mission");
|
||||||
|
cfg.Config.Rotation.CardSetIdForResourceDlView = GetInt(loadIndex, "card_set_id_for_resource_dl_view");
|
||||||
|
|
||||||
if (loadIndex.TryGetProperty("challenge_config", out var cc))
|
if (loadIndex.TryGetProperty("challenge_config", out var cc))
|
||||||
{
|
{
|
||||||
cfg.ChallengeUseTwoPickPremiumCard = GetBool(cc, "use_challenge_two_pick_premium_card");
|
cfg.Config.Challenge.UseTwoPickPremiumCard = GetBool(cc, "use_challenge_two_pick_premium_card");
|
||||||
cfg.ChallengeTwoPickSleeveId = GetLong(cc, "challenge_two_pick_sleeve_id");
|
cfg.Config.Challenge.TwoPickSleeveId = GetLong(cc, "challenge_two_pick_sleeve_id");
|
||||||
}
|
}
|
||||||
|
|
||||||
Console.WriteLine($"[GlobalsImporter] GameConfiguration extensions: ts_rotation_id={cfg.TsRotationId}");
|
Console.WriteLine($"[GlobalsImporter] GameConfiguration extensions: ts_rotation_id={cfg.Config.Rotation.TsRotationId}");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
using SVSim.Database.Common;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using SVSim.Database.Models;
|
using SVSim.Database.Common;
|
||||||
|
|
||||||
namespace SVSim.Database.DataSeeders;
|
namespace SVSim.Database.DataSeeders;
|
||||||
|
|
||||||
@@ -8,21 +7,10 @@ public class DefaultSettingsSeeder : IDataSeeder
|
|||||||
{
|
{
|
||||||
public void Seed(ModelBuilder builder)
|
public void Seed(ModelBuilder builder)
|
||||||
{
|
{
|
||||||
builder.Entity<GameConfiguration>().HasData(new GameConfiguration
|
// GameConfiguration is seeded at runtime (see SVSimDbContext.EnsureSeedDataAsync),
|
||||||
{
|
// NOT via HasData — EF Core 8's HasData+OwnsOne(...).ToJson combo doesn't reliably
|
||||||
Id = "default",
|
// populate the jsonb cell, leading to NOT NULL violations on a fresh DB. Runtime
|
||||||
DefaultCrystals = 50000,
|
// seeding writes a full `GameConfigRoot()` (all sub-config POCO initialisers
|
||||||
DefaultRupees = 50000,
|
// produce the canonical defaults).
|
||||||
DefaultEther = 50000,
|
|
||||||
MaxFriends = 20,
|
|
||||||
DefaultEmblemId = 100000000,
|
|
||||||
DefaultDegreeId = 300003,
|
|
||||||
DefaultMyPageBackgroundId = 100000000,
|
|
||||||
DefaultSleeveId = 3000011,
|
|
||||||
DefaultDegree = null,
|
|
||||||
DefaultEmblem = null,
|
|
||||||
DefaultSleeve = null,
|
|
||||||
DefaultMyPageBackground = null
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
34971
SVSim.Database/Migrations/20260524065010_RefactorGameConfigurationToJsonb.Designer.cs
generated
Normal file
34971
SVSim.Database/Migrations/20260524065010_RefactorGameConfigurationToJsonb.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,338 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace SVSim.Database.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class RefactorGameConfigurationToJsonb : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// Step 1: Add the new jsonb column FIRST so the backfill SQL can read the old columns.
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Config",
|
||||||
|
table: "GameConfigurations",
|
||||||
|
type: "jsonb",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
|
||||||
|
// Step 2: Backfill — project old flat columns into the new jsonb document.
|
||||||
|
// For fresh-install databases (no pre-existing rows) this is a no-op; the
|
||||||
|
// DefaultSettingsSeeder InsertData in the previous migration already seeded the
|
||||||
|
// parent row, so the UPDATE below covers it. PackRates defaults are hard-coded
|
||||||
|
// here because the old schema had no equivalent columns.
|
||||||
|
// Use provider-specific SQL: PostgreSQL jsonb functions vs SQLite json functions.
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"""
|
||||||
|
UPDATE "GameConfigurations"
|
||||||
|
SET "Config" = jsonb_build_object(
|
||||||
|
'DefaultGrants', jsonb_build_object(
|
||||||
|
'Crystals', "DefaultCrystals",
|
||||||
|
'Rupees', "DefaultRupees",
|
||||||
|
'Ether', "DefaultEther"
|
||||||
|
),
|
||||||
|
'Player', jsonb_build_object(
|
||||||
|
'MaxFriends', "MaxFriends"
|
||||||
|
),
|
||||||
|
'DefaultLoadout', jsonb_build_object(
|
||||||
|
'DegreeId', "DefaultDegreeId",
|
||||||
|
'EmblemId', "DefaultEmblemId",
|
||||||
|
'MyPageBackgroundId', "DefaultMyPageBackgroundId",
|
||||||
|
'SleeveId', "DefaultSleeveId"
|
||||||
|
),
|
||||||
|
'Challenge', jsonb_build_object(
|
||||||
|
'UseTwoPickPremiumCard', "ChallengeUseTwoPickPremiumCard",
|
||||||
|
'TwoPickSleeveId', "ChallengeTwoPickSleeveId"
|
||||||
|
),
|
||||||
|
'Rotation', jsonb_build_object(
|
||||||
|
'TsRotationId', "TsRotationId",
|
||||||
|
'IsBattlePassPeriod', "IsBattlePassPeriod",
|
||||||
|
'IsBeginnerMission', "IsBeginnerMission",
|
||||||
|
'CardSetIdForResourceDlView', "CardSetIdForResourceDlView"
|
||||||
|
),
|
||||||
|
'PackRates', jsonb_build_object(
|
||||||
|
'AnimatedRate', 0.08,
|
||||||
|
'Default', jsonb_build_object('Bronze', 0.6744, 'Silver', 0.25, 'Gold', 0.06, 'Legendary', 0.015),
|
||||||
|
'PerSlot', jsonb_build_array(
|
||||||
|
jsonb_build_object('Slot', '8', 'Bronze', 0, 'Silver', 0.7692, 'Gold', 0.1846, 'Legendary', 0.0462)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
WHERE "Id" = 'default';
|
||||||
|
""",
|
||||||
|
suppressTransaction: true);
|
||||||
|
|
||||||
|
// For SQLite (tests): use json() and json_object() functions.
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"""
|
||||||
|
UPDATE "GameConfigurations"
|
||||||
|
SET "Config" = json('{"DefaultGrants":{"Crystals":'||COALESCE("DefaultCrystals", 0)||',"Rupees":'||COALESCE("DefaultRupees", 0)||',"Ether":'||COALESCE("DefaultEther", 0)||'},'||
|
||||||
|
'"Player":{"MaxFriends":'||COALESCE("MaxFriends", 0)||'},'||
|
||||||
|
'"DefaultLoadout":{"DegreeId":'||COALESCE("DefaultDegreeId", 0)||',"EmblemId":'||COALESCE("DefaultEmblemId", 0)||',"MyPageBackgroundId":'||COALESCE("DefaultMyPageBackgroundId", 0)||',"SleeveId":'||COALESCE("DefaultSleeveId", 0)||'},'||
|
||||||
|
'"Challenge":{"UseTwoPickPremiumCard":'||(CASE WHEN "ChallengeUseTwoPickPremiumCard" THEN 'true' ELSE 'false' END)||',"TwoPickSleeveId":'||COALESCE("ChallengeTwoPickSleeveId", 0)||'},'||
|
||||||
|
'"Rotation":{"TsRotationId":"'||COALESCE("TsRotationId", '')||'","IsBattlePassPeriod":'||(CASE WHEN "IsBattlePassPeriod" THEN 'true' ELSE 'false' END)||',"IsBeginnerMission":'||(CASE WHEN "IsBeginnerMission" THEN 'true' ELSE 'false' END)||',"CardSetIdForResourceDlView":'||COALESCE("CardSetIdForResourceDlView", 0)||'},'||
|
||||||
|
'"PackRates":{"AnimatedRate":0.08,"Default":{"Bronze":0.6744,"Silver":0.25,"Gold":0.06,"Legendary":0.015},"PerSlot":[{"Slot":"8","Bronze":0,"Silver":0.7692,"Gold":0.1846,"Legendary":0.0462}]}'||
|
||||||
|
'}')
|
||||||
|
WHERE "Id" = 'default';
|
||||||
|
""",
|
||||||
|
suppressTransaction: true);
|
||||||
|
|
||||||
|
|
||||||
|
// Step 3: Drop FK constraints, indexes, and old flat columns.
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_GameConfigurations_Degrees_DefaultDegreeId",
|
||||||
|
table: "GameConfigurations");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_GameConfigurations_Emblems_DefaultEmblemId",
|
||||||
|
table: "GameConfigurations");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_GameConfigurations_MyPageBackgrounds_DefaultMyPageBackgroun~",
|
||||||
|
table: "GameConfigurations");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_GameConfigurations_Sleeves_DefaultSleeveId",
|
||||||
|
table: "GameConfigurations");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_GameConfigurations_DefaultDegreeId",
|
||||||
|
table: "GameConfigurations");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_GameConfigurations_DefaultEmblemId",
|
||||||
|
table: "GameConfigurations");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_GameConfigurations_DefaultMyPageBackgroundId",
|
||||||
|
table: "GameConfigurations");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_GameConfigurations_DefaultSleeveId",
|
||||||
|
table: "GameConfigurations");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CardSetIdForResourceDlView",
|
||||||
|
table: "GameConfigurations");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ChallengeTwoPickSleeveId",
|
||||||
|
table: "GameConfigurations");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ChallengeUseTwoPickPremiumCard",
|
||||||
|
table: "GameConfigurations");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "DefaultCrystals",
|
||||||
|
table: "GameConfigurations");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "DefaultDegreeId",
|
||||||
|
table: "GameConfigurations");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "DefaultEmblemId",
|
||||||
|
table: "GameConfigurations");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "DefaultEther",
|
||||||
|
table: "GameConfigurations");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "DefaultMyPageBackgroundId",
|
||||||
|
table: "GameConfigurations");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "DefaultRupees",
|
||||||
|
table: "GameConfigurations");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "DefaultSleeveId",
|
||||||
|
table: "GameConfigurations");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsBattlePassPeriod",
|
||||||
|
table: "GameConfigurations");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsBeginnerMission",
|
||||||
|
table: "GameConfigurations");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "MaxFriends",
|
||||||
|
table: "GameConfigurations");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "TsRotationId",
|
||||||
|
table: "GameConfigurations");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Config",
|
||||||
|
table: "GameConfigurations");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "CardSetIdForResourceDlView",
|
||||||
|
table: "GameConfigurations",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<long>(
|
||||||
|
name: "ChallengeTwoPickSleeveId",
|
||||||
|
table: "GameConfigurations",
|
||||||
|
type: "bigint",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0L);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "ChallengeUseTwoPickPremiumCard",
|
||||||
|
table: "GameConfigurations",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "DefaultCrystals",
|
||||||
|
table: "GameConfigurations",
|
||||||
|
type: "numeric(20,0)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0m);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "DefaultDegreeId",
|
||||||
|
table: "GameConfigurations",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "DefaultEmblemId",
|
||||||
|
table: "GameConfigurations",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "DefaultEther",
|
||||||
|
table: "GameConfigurations",
|
||||||
|
type: "numeric(20,0)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0m);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "DefaultMyPageBackgroundId",
|
||||||
|
table: "GameConfigurations",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "DefaultRupees",
|
||||||
|
table: "GameConfigurations",
|
||||||
|
type: "numeric(20,0)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0m);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "DefaultSleeveId",
|
||||||
|
table: "GameConfigurations",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsBattlePassPeriod",
|
||||||
|
table: "GameConfigurations",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsBeginnerMission",
|
||||||
|
table: "GameConfigurations",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "MaxFriends",
|
||||||
|
table: "GameConfigurations",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "TsRotationId",
|
||||||
|
table: "GameConfigurations",
|
||||||
|
type: "text",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "GameConfigurations",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: "default",
|
||||||
|
columns: new[] { "CardSetIdForResourceDlView", "ChallengeTwoPickSleeveId", "ChallengeUseTwoPickPremiumCard", "DefaultCrystals", "DefaultDegreeId", "DefaultEmblemId", "DefaultEther", "DefaultMyPageBackgroundId", "DefaultRupees", "DefaultSleeveId", "IsBattlePassPeriod", "IsBeginnerMission", "MaxFriends", "TsRotationId" },
|
||||||
|
values: new object[] { 0, 0L, false, 50000m, 300003, 100000000, 50000m, 100000000, 50000m, 3000011, false, false, 20, "" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_GameConfigurations_DefaultDegreeId",
|
||||||
|
table: "GameConfigurations",
|
||||||
|
column: "DefaultDegreeId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_GameConfigurations_DefaultEmblemId",
|
||||||
|
table: "GameConfigurations",
|
||||||
|
column: "DefaultEmblemId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_GameConfigurations_DefaultMyPageBackgroundId",
|
||||||
|
table: "GameConfigurations",
|
||||||
|
column: "DefaultMyPageBackgroundId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_GameConfigurations_DefaultSleeveId",
|
||||||
|
table: "GameConfigurations",
|
||||||
|
column: "DefaultSleeveId");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_GameConfigurations_Degrees_DefaultDegreeId",
|
||||||
|
table: "GameConfigurations",
|
||||||
|
column: "DefaultDegreeId",
|
||||||
|
principalTable: "Degrees",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_GameConfigurations_Emblems_DefaultEmblemId",
|
||||||
|
table: "GameConfigurations",
|
||||||
|
column: "DefaultEmblemId",
|
||||||
|
principalTable: "Emblems",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_GameConfigurations_MyPageBackgrounds_DefaultMyPageBackgroun~",
|
||||||
|
table: "GameConfigurations",
|
||||||
|
column: "DefaultMyPageBackgroundId",
|
||||||
|
principalTable: "MyPageBackgrounds",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_GameConfigurations_Sleeves_DefaultSleeveId",
|
||||||
|
table: "GameConfigurations",
|
||||||
|
column: "DefaultSleeveId",
|
||||||
|
principalTable: "Sleeves",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
34967
SVSim.Database/Migrations/20260524071358_AddIsFoilToCards.Designer.cs
generated
Normal file
34967
SVSim.Database/Migrations/20260524071358_AddIsFoilToCards.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
SVSim.Database/Migrations/20260524071358_AddIsFoilToCards.cs
Normal file
29
SVSim.Database/Migrations/20260524071358_AddIsFoilToCards.cs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace SVSim.Database.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddIsFoilToCards : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsFoil",
|
||||||
|
table: "Cards",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsFoil",
|
||||||
|
table: "Cards");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21730,87 +21730,15 @@ namespace SVSim.Database.Migrations
|
|||||||
b.Property<string>("Id")
|
b.Property<string>("Id")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<int>("CardSetIdForResourceDlView")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<long>("ChallengeTwoPickSleeveId")
|
|
||||||
.HasColumnType("bigint");
|
|
||||||
|
|
||||||
b.Property<bool>("ChallengeUseTwoPickPremiumCard")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<DateTime>("DateCreated")
|
b.Property<DateTime>("DateCreated")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
b.Property<DateTime?>("DateUpdated")
|
b.Property<DateTime?>("DateUpdated")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
b.Property<decimal>("DefaultCrystals")
|
|
||||||
.HasColumnType("numeric(20,0)");
|
|
||||||
|
|
||||||
b.Property<int>("DefaultDegreeId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("DefaultEmblemId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<decimal>("DefaultEther")
|
|
||||||
.HasColumnType("numeric(20,0)");
|
|
||||||
|
|
||||||
b.Property<int>("DefaultMyPageBackgroundId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<decimal>("DefaultRupees")
|
|
||||||
.HasColumnType("numeric(20,0)");
|
|
||||||
|
|
||||||
b.Property<int>("DefaultSleeveId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<bool>("IsBattlePassPeriod")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<bool>("IsBeginnerMission")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<int>("MaxFriends")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("TsRotationId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("DefaultDegreeId");
|
|
||||||
|
|
||||||
b.HasIndex("DefaultEmblemId");
|
|
||||||
|
|
||||||
b.HasIndex("DefaultMyPageBackgroundId");
|
|
||||||
|
|
||||||
b.HasIndex("DefaultSleeveId");
|
|
||||||
|
|
||||||
b.ToTable("GameConfigurations");
|
b.ToTable("GameConfigurations");
|
||||||
|
|
||||||
b.HasData(
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = "default",
|
|
||||||
CardSetIdForResourceDlView = 0,
|
|
||||||
ChallengeTwoPickSleeveId = 0L,
|
|
||||||
ChallengeUseTwoPickPremiumCard = false,
|
|
||||||
DateCreated = new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
|
||||||
DefaultCrystals = 50000m,
|
|
||||||
DefaultDegreeId = 300003,
|
|
||||||
DefaultEmblemId = 100000000,
|
|
||||||
DefaultEther = 50000m,
|
|
||||||
DefaultMyPageBackgroundId = 100000000,
|
|
||||||
DefaultRupees = 50000m,
|
|
||||||
DefaultSleeveId = 3000011,
|
|
||||||
IsBattlePassPeriod = false,
|
|
||||||
IsBeginnerMission = false,
|
|
||||||
MaxFriends = 20,
|
|
||||||
TsRotationId = ""
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("SVSim.Database.Models.ItemEntry", b =>
|
modelBuilder.Entity("SVSim.Database.Models.ItemEntry", b =>
|
||||||
@@ -26427,6 +26355,9 @@ namespace SVSim.Database.Migrations
|
|||||||
b.Property<int?>("Defense")
|
b.Property<int?>("Defense")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<bool>("IsFoil")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
@@ -34232,37 +34163,230 @@ namespace SVSim.Database.Migrations
|
|||||||
|
|
||||||
modelBuilder.Entity("SVSim.Database.Models.GameConfiguration", b =>
|
modelBuilder.Entity("SVSim.Database.Models.GameConfiguration", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("SVSim.Database.Models.DegreeEntry", "DefaultDegree")
|
b.OwnsOne("SVSim.Database.Models.GameConfigRoot", "Config", b1 =>
|
||||||
.WithMany()
|
{
|
||||||
.HasForeignKey("DefaultDegreeId")
|
b1.Property<string>("GameConfigurationId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b1.HasKey("GameConfigurationId");
|
||||||
|
|
||||||
|
b1.ToTable("GameConfigurations");
|
||||||
|
|
||||||
|
b1.ToJson("Config");
|
||||||
|
|
||||||
|
b1.WithOwner()
|
||||||
|
.HasForeignKey("GameConfigurationId");
|
||||||
|
|
||||||
|
b1.OwnsOne("SVSim.Database.Models.Config.ChallengeConfig", "Challenge", b2 =>
|
||||||
|
{
|
||||||
|
b2.Property<string>("GameConfigRootGameConfigurationId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b2.Property<long>("TwoPickSleeveId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b2.Property<bool>("UseTwoPickPremiumCard")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b2.HasKey("GameConfigRootGameConfigurationId");
|
||||||
|
|
||||||
|
b2.ToTable("GameConfigurations");
|
||||||
|
|
||||||
|
b2.WithOwner()
|
||||||
|
.HasForeignKey("GameConfigRootGameConfigurationId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b1.OwnsOne("SVSim.Database.Models.Config.DefaultGrantsConfig", "DefaultGrants", b2 =>
|
||||||
|
{
|
||||||
|
b2.Property<string>("GameConfigRootGameConfigurationId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b2.Property<decimal>("Crystals")
|
||||||
|
.HasColumnType("numeric(20,0)");
|
||||||
|
|
||||||
|
b2.Property<decimal>("Ether")
|
||||||
|
.HasColumnType("numeric(20,0)");
|
||||||
|
|
||||||
|
b2.Property<decimal>("Rupees")
|
||||||
|
.HasColumnType("numeric(20,0)");
|
||||||
|
|
||||||
|
b2.HasKey("GameConfigRootGameConfigurationId");
|
||||||
|
|
||||||
|
b2.ToTable("GameConfigurations");
|
||||||
|
|
||||||
|
b2.WithOwner()
|
||||||
|
.HasForeignKey("GameConfigRootGameConfigurationId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b1.OwnsOne("SVSim.Database.Models.Config.DefaultLoadoutConfig", "DefaultLoadout", b2 =>
|
||||||
|
{
|
||||||
|
b2.Property<string>("GameConfigRootGameConfigurationId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b2.Property<int>("DegreeId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b2.Property<int>("EmblemId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b2.Property<int>("MyPageBackgroundId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b2.Property<int>("SleeveId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b2.HasKey("GameConfigRootGameConfigurationId");
|
||||||
|
|
||||||
|
b2.ToTable("GameConfigurations");
|
||||||
|
|
||||||
|
b2.WithOwner()
|
||||||
|
.HasForeignKey("GameConfigRootGameConfigurationId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b1.OwnsOne("SVSim.Database.Models.Config.PackRateConfig", "PackRates", b2 =>
|
||||||
|
{
|
||||||
|
b2.Property<string>("GameConfigRootGameConfigurationId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b2.Property<double>("AnimatedRate")
|
||||||
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
|
b2.HasKey("GameConfigRootGameConfigurationId");
|
||||||
|
|
||||||
|
b2.ToTable("GameConfigurations");
|
||||||
|
|
||||||
|
b2.WithOwner()
|
||||||
|
.HasForeignKey("GameConfigRootGameConfigurationId");
|
||||||
|
|
||||||
|
b2.OwnsOne("SVSim.Database.Models.Config.SlotRarityWeights", "Default", b3 =>
|
||||||
|
{
|
||||||
|
b3.Property<string>("PackRateConfigGameConfigRootGameConfigurationId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b3.Property<double>("Bronze")
|
||||||
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
|
b3.Property<double>("Gold")
|
||||||
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
|
b3.Property<double>("Legendary")
|
||||||
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
|
b3.Property<double>("Silver")
|
||||||
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
|
b3.Property<string>("Slot")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b3.HasKey("PackRateConfigGameConfigRootGameConfigurationId");
|
||||||
|
|
||||||
|
b3.ToTable("GameConfigurations");
|
||||||
|
|
||||||
|
b3.WithOwner()
|
||||||
|
.HasForeignKey("PackRateConfigGameConfigRootGameConfigurationId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b2.OwnsMany("SVSim.Database.Models.Config.SlotRarityWeights", "PerSlot", b3 =>
|
||||||
|
{
|
||||||
|
b3.Property<string>("PackRateConfigGameConfigRootGameConfigurationId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b3.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b3.Property<double>("Bronze")
|
||||||
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
|
b3.Property<double>("Gold")
|
||||||
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
|
b3.Property<double>("Legendary")
|
||||||
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
|
b3.Property<double>("Silver")
|
||||||
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
|
b3.Property<string>("Slot")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b3.HasKey("PackRateConfigGameConfigRootGameConfigurationId", "Id");
|
||||||
|
|
||||||
|
b3.ToTable("GameConfigurations");
|
||||||
|
|
||||||
|
b3.WithOwner()
|
||||||
|
.HasForeignKey("PackRateConfigGameConfigRootGameConfigurationId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b2.Navigation("Default")
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b2.Navigation("PerSlot");
|
||||||
|
});
|
||||||
|
|
||||||
|
b1.OwnsOne("SVSim.Database.Models.Config.PlayerConfig", "Player", b2 =>
|
||||||
|
{
|
||||||
|
b2.Property<string>("GameConfigRootGameConfigurationId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b2.Property<int>("MaxFriends")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b2.HasKey("GameConfigRootGameConfigurationId");
|
||||||
|
|
||||||
|
b2.ToTable("GameConfigurations");
|
||||||
|
|
||||||
|
b2.WithOwner()
|
||||||
|
.HasForeignKey("GameConfigRootGameConfigurationId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b1.OwnsOne("SVSim.Database.Models.Config.RotationConfig", "Rotation", b2 =>
|
||||||
|
{
|
||||||
|
b2.Property<string>("GameConfigRootGameConfigurationId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b2.Property<int>("CardSetIdForResourceDlView")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b2.Property<bool>("IsBattlePassPeriod")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b2.Property<bool>("IsBeginnerMission")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b2.Property<string>("TsRotationId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b2.HasKey("GameConfigRootGameConfigurationId");
|
||||||
|
|
||||||
|
b2.ToTable("GameConfigurations");
|
||||||
|
|
||||||
|
b2.WithOwner()
|
||||||
|
.HasForeignKey("GameConfigRootGameConfigurationId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b1.Navigation("Challenge")
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b1.Navigation("DefaultGrants")
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b1.Navigation("DefaultLoadout")
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b1.Navigation("PackRates")
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b1.Navigation("Player")
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b1.Navigation("Rotation")
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
b.Navigation("Config")
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
b.HasOne("SVSim.Database.Models.EmblemEntry", "DefaultEmblem")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("DefaultEmblemId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("SVSim.Database.Models.MyPageBackgroundEntry", "DefaultMyPageBackground")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("DefaultMyPageBackgroundId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("SVSim.Database.Models.SleeveEntry", "DefaultSleeve")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("DefaultSleeveId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("DefaultDegree");
|
|
||||||
|
|
||||||
b.Navigation("DefaultEmblem");
|
|
||||||
|
|
||||||
b.Navigation("DefaultMyPageBackground");
|
|
||||||
|
|
||||||
b.Navigation("DefaultSleeve");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("SVSim.Database.Models.LeaderSkinEntry", b =>
|
modelBuilder.Entity("SVSim.Database.Models.LeaderSkinEntry", b =>
|
||||||
|
|||||||
10
SVSim.Database/Models/Config/ChallengeConfig.cs
Normal file
10
SVSim.Database/Models/Config/ChallengeConfig.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace SVSim.Database.Models.Config;
|
||||||
|
|
||||||
|
[Owned]
|
||||||
|
public class ChallengeConfig
|
||||||
|
{
|
||||||
|
public bool UseTwoPickPremiumCard { get; set; }
|
||||||
|
public long TwoPickSleeveId { get; set; }
|
||||||
|
}
|
||||||
12
SVSim.Database/Models/Config/DefaultGrantsConfig.cs
Normal file
12
SVSim.Database/Models/Config/DefaultGrantsConfig.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace SVSim.Database.Models.Config;
|
||||||
|
|
||||||
|
/// <summary>Per-viewer-registration default currency grants.</summary>
|
||||||
|
[Owned]
|
||||||
|
public class DefaultGrantsConfig
|
||||||
|
{
|
||||||
|
public ulong Crystals { get; set; } = 50000;
|
||||||
|
public ulong Rupees { get; set; } = 50000;
|
||||||
|
public ulong Ether { get; set; } = 50000;
|
||||||
|
}
|
||||||
17
SVSim.Database/Models/Config/DefaultLoadoutConfig.cs
Normal file
17
SVSim.Database/Models/Config/DefaultLoadoutConfig.cs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace SVSim.Database.Models.Config;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Default cosmetic loadout ids for a newly-registered viewer. These used to be FK columns;
|
||||||
|
/// they're now untyped longs in the jsonb tree. Validation would live in a future config-editing
|
||||||
|
/// UI (see project-wide TODO(config-validation)).
|
||||||
|
/// </summary>
|
||||||
|
[Owned]
|
||||||
|
public class DefaultLoadoutConfig
|
||||||
|
{
|
||||||
|
public int DegreeId { get; set; } = 300003;
|
||||||
|
public int EmblemId { get; set; } = 100000000;
|
||||||
|
public int MyPageBackgroundId { get; set; } = 100000000;
|
||||||
|
public int SleeveId { get; set; } = 3000011;
|
||||||
|
}
|
||||||
47
SVSim.Database/Models/Config/PackRateConfig.cs
Normal file
47
SVSim.Database/Models/Config/PackRateConfig.cs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace SVSim.Database.Models.Config;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tunables for pack-opening RNG. Defaults reproduce the original Shadowverse Classic rates
|
||||||
|
/// exactly so the cutover from hardcoded magic numbers is zero-behavior-change.
|
||||||
|
/// </summary>
|
||||||
|
[Owned]
|
||||||
|
public class PackRateConfig
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Per-card-slot probability of upgrading any drawn card to its foil/animated twin.
|
||||||
|
/// Applied AFTER rarity selection — independent of rarity, slot position, and pack category.
|
||||||
|
/// Default 0.08 (8%). Cards without a foil twin in master data keep the non-foil silently.
|
||||||
|
/// </summary>
|
||||||
|
public double AnimatedRate { get; set; } = 0.08;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Global default rarity weights, used for any slot that has no entry in
|
||||||
|
/// <see cref="PerSlot"/>. Defaults match SV Classic main-slot. Weights sum to 0.9994;
|
||||||
|
/// the 0.06% slack absorbs into Bronze via the PickRarity catch-all band.
|
||||||
|
/// </summary>
|
||||||
|
public SlotRarityWeights Default { get; set; } = new()
|
||||||
|
{
|
||||||
|
Bronze = 0.6744, Silver = 0.25, Gold = 0.06, Legendary = 0.015,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-slot overrides (1-based slot index) applied to all packs. A missing slot falls back
|
||||||
|
/// to <see cref="Default"/>. Each entry is a FULL OVERRIDE, not a delta — if you change
|
||||||
|
/// <see cref="Default"/>, existing PerSlot entries do NOT auto-recompute. The slot-8 default
|
||||||
|
/// expresses the SV Classic "Silver-or-better guarantee" as data (Bronze=0) instead of a
|
||||||
|
/// separate code path.
|
||||||
|
/// </summary>
|
||||||
|
/// <summary>
|
||||||
|
/// Per-slot overrides keyed by 1-based slot index (stored as a list for EF Core 8 json
|
||||||
|
/// compatibility — Dictionary<string,T> of complex owned types is not supported).
|
||||||
|
/// Look up by <see cref="SlotRarityWeights.Slot"/>. A missing slot falls back to
|
||||||
|
/// <see cref="Default"/>. Slot-8 entry expresses the SV Classic "Silver-or-better
|
||||||
|
/// guarantee" as data (Bronze=0).
|
||||||
|
/// </summary>
|
||||||
|
public List<SlotRarityWeights> PerSlot { get; set; } =
|
||||||
|
[
|
||||||
|
new() { Slot = "8", Bronze = 0, Silver = 0.7692, Gold = 0.1846, Legendary = 0.0462 },
|
||||||
|
];
|
||||||
|
}
|
||||||
9
SVSim.Database/Models/Config/PlayerConfig.cs
Normal file
9
SVSim.Database/Models/Config/PlayerConfig.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace SVSim.Database.Models.Config;
|
||||||
|
|
||||||
|
[Owned]
|
||||||
|
public class PlayerConfig
|
||||||
|
{
|
||||||
|
public int MaxFriends { get; set; } = 20;
|
||||||
|
}
|
||||||
15
SVSim.Database/Models/Config/RotationConfig.cs
Normal file
15
SVSim.Database/Models/Config/RotationConfig.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace SVSim.Database.Models.Config;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Time-varying season/rotation state, populated by GlobalsImporter from prod captures.
|
||||||
|
/// </summary>
|
||||||
|
[Owned]
|
||||||
|
public class RotationConfig
|
||||||
|
{
|
||||||
|
public string TsRotationId { get; set; } = "";
|
||||||
|
public bool IsBattlePassPeriod { get; set; }
|
||||||
|
public bool IsBeginnerMission { get; set; }
|
||||||
|
public int CardSetIdForResourceDlView { get; set; }
|
||||||
|
}
|
||||||
23
SVSim.Database/Models/Config/SlotRarityWeights.cs
Normal file
23
SVSim.Database/Models/Config/SlotRarityWeights.cs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace SVSim.Database.Models.Config;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-rarity weights for a single pack slot. Sum should be ≤ 1.0;
|
||||||
|
/// remainder absorbs into Bronze via the PickRarity catch-all band.
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="Slot"/> is the 1-based slot index as a string (e.g. "8") and is used as the
|
||||||
|
/// lookup key in <see cref="PackRateConfig.PerSlot"/>. It is empty/null for the global
|
||||||
|
/// <see cref="PackRateConfig.Default"/> entry, which has no slot affiliation.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
[Owned]
|
||||||
|
public class SlotRarityWeights
|
||||||
|
{
|
||||||
|
/// <summary>1-based slot index (as a string) for entries in PerSlot. Null/empty for the Default entry.</summary>
|
||||||
|
public string? Slot { get; set; }
|
||||||
|
public double Bronze { get; set; }
|
||||||
|
public double Silver { get; set; }
|
||||||
|
public double Gold { get; set; }
|
||||||
|
public double Legendary { get; set; }
|
||||||
|
}
|
||||||
20
SVSim.Database/Models/GameConfigRoot.cs
Normal file
20
SVSim.Database/Models/GameConfigRoot.cs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SVSim.Database.Models.Config;
|
||||||
|
|
||||||
|
namespace SVSim.Database.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The root of <see cref="GameConfiguration.Config"/>, stored as a single jsonb column.
|
||||||
|
/// Each sub-object defaults to its own initialiser, so `new GameConfigRoot()` is fully populated
|
||||||
|
/// with the canonical SV Classic / DCGEngine defaults.
|
||||||
|
/// </summary>
|
||||||
|
[Owned]
|
||||||
|
public class GameConfigRoot
|
||||||
|
{
|
||||||
|
public DefaultGrantsConfig DefaultGrants { get; set; } = new();
|
||||||
|
public PlayerConfig Player { get; set; } = new();
|
||||||
|
public DefaultLoadoutConfig DefaultLoadout { get; set; } = new();
|
||||||
|
public ChallengeConfig Challenge { get; set; } = new();
|
||||||
|
public RotationConfig Rotation { get; set; } = new();
|
||||||
|
public PackRateConfig PackRates { get; set; } = new();
|
||||||
|
}
|
||||||
@@ -2,56 +2,13 @@ using SVSim.Database.Common;
|
|||||||
|
|
||||||
namespace SVSim.Database.Models;
|
namespace SVSim.Database.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server-wide tunable config and captured-from-prod state. Singleton (Id = "default") with
|
||||||
|
/// all data living in a single typed jsonb column. See <see cref="GameConfigRoot"/> for the
|
||||||
|
/// schema. Pre-refactor this entity had ~14 flat columns plus 4 FK navs — see migration
|
||||||
|
/// `RefactorGameConfigurationToJsonb` for the cutover.
|
||||||
|
/// </summary>
|
||||||
public class GameConfiguration : BaseEntity<string>
|
public class GameConfiguration : BaseEntity<string>
|
||||||
{
|
{
|
||||||
public ulong DefaultCrystals { get; set; }
|
public GameConfigRoot Config { get; set; } = new();
|
||||||
|
|
||||||
public ulong DefaultRupees { get; set; }
|
|
||||||
|
|
||||||
public ulong DefaultEther { get; set; }
|
|
||||||
|
|
||||||
public int MaxFriends { get; set; }
|
|
||||||
|
|
||||||
#region Time-varying globals populated by SVSim.Bootstrap.GlobalsImporter
|
|
||||||
|
|
||||||
/// <summary>Current "Take Two Special" rotation ID, e.g. "10015". Points into MyRotationSettingEntry.</summary>
|
|
||||||
public string TsRotationId { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
public bool ChallengeUseTwoPickPremiumCard { get; set; }
|
|
||||||
|
|
||||||
public long ChallengeTwoPickSleeveId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Bool on the wire (prod sends true/false); local previously sent int. Fixes the
|
|
||||||
/// type-mismatch noted in seed-data-strategy-2026-05-23.md crash audit.
|
|
||||||
/// </summary>
|
|
||||||
public bool IsBattlePassPeriod { get; set; }
|
|
||||||
|
|
||||||
public bool IsBeginnerMission { get; set; }
|
|
||||||
|
|
||||||
public int CardSetIdForResourceDlView { get; set; }
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Foreign Keys
|
|
||||||
|
|
||||||
public int DefaultDegreeId { get; set; }
|
|
||||||
|
|
||||||
public int DefaultEmblemId { get; set; }
|
|
||||||
|
|
||||||
public int DefaultMyPageBackgroundId { get; set; }
|
|
||||||
|
|
||||||
public int DefaultSleeveId { get; set; }
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Navigation Properties
|
|
||||||
|
|
||||||
public DegreeEntry DefaultDegree { get; set; } = new DegreeEntry();
|
|
||||||
public EmblemEntry DefaultEmblem { get; set; } = new EmblemEntry();
|
|
||||||
public MyPageBackgroundEntry DefaultMyPageBackground { get; set; } = new MyPageBackgroundEntry();
|
|
||||||
public SleeveEntry DefaultSleeve { get; set; } = new SleeveEntry();
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -32,6 +32,14 @@ public class ShadowverseCardEntry : BaseEntity<long>
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public Rarity Rarity { get; set; }
|
public Rarity Rarity { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True for foil/animated card rows (cards.json `is_foil=1`). Foils live in the same
|
||||||
|
/// CardSet as their non-foil twin (twin's card_id = this.Id - 1). Excluded from pack
|
||||||
|
/// draw pools by DbCardPoolProvider; reached via the per-card animated-upgrade roll
|
||||||
|
/// in PackOpenService.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsFoil { get; set; }
|
||||||
|
|
||||||
#region Owned
|
#region Owned
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ public class GlobalsRepository : IGlobalsRepository
|
|||||||
|
|
||||||
public async Task<GameConfiguration> GetGameConfiguration(string key)
|
public async Task<GameConfiguration> GetGameConfiguration(string key)
|
||||||
{
|
{
|
||||||
return await _dbContext.Set<GameConfiguration>().Include(gc => gc.DefaultMyPageBackground)
|
// TODO: fixed in Tasks 6-7 — Include() calls removed because nav props dropped in RefactorGameConfigurationToJsonb
|
||||||
.Include(gc => gc.DefaultEmblem).Include(gc => gc.DefaultDegree).Include(gc => gc.DefaultSleeve).FirstOrDefaultAsync(gc => gc.Id == key) ??
|
return await _dbContext.Set<GameConfiguration>().FirstOrDefaultAsync(gc => gc.Id == key) ??
|
||||||
new GameConfiguration();
|
new GameConfiguration();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -81,12 +81,13 @@ public class ViewerRepository : IViewerRepository
|
|||||||
AccountType = socialType
|
AccountType = socialType
|
||||||
});
|
});
|
||||||
|
|
||||||
viewer.Info.MaxFriends = gameConfig.MaxFriends;
|
// TODO: fixed in Task 7 — reads via Config tree after RefactorGameConfigurationToJsonb
|
||||||
|
viewer.Info.MaxFriends = gameConfig.Config.Player.MaxFriends;
|
||||||
viewer.Info.CountryCode = "KOR";
|
viewer.Info.CountryCode = "KOR";
|
||||||
viewer.Info.BirthDate = DateTime.UtcNow;
|
viewer.Info.BirthDate = DateTime.UtcNow;
|
||||||
viewer.Currency.Crystals = gameConfig.DefaultCrystals;
|
viewer.Currency.Crystals = gameConfig.Config.DefaultGrants.Crystals;
|
||||||
viewer.Currency.Rupees = gameConfig.DefaultRupees;
|
viewer.Currency.Rupees = gameConfig.Config.DefaultGrants.Rupees;
|
||||||
viewer.Currency.RedEther = gameConfig.DefaultEther;
|
viewer.Currency.RedEther = gameConfig.Config.DefaultGrants.Ether;
|
||||||
viewer.MissionData.TutorialState = 100; // finishes tutorial for now
|
viewer.MissionData.TutorialState = 100; // finishes tutorial for now
|
||||||
|
|
||||||
// Load classes WITH their LeaderSkins — DefaultLeaderSkin iterates the nav collection
|
// Load classes WITH their LeaderSkins — DefaultLeaderSkin iterates the nav collection
|
||||||
@@ -107,10 +108,19 @@ public class ViewerRepository : IViewerRepository
|
|||||||
};
|
};
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
if (gameConfig.DefaultSleeve is not null) viewer.Sleeves.Add(gameConfig.DefaultSleeve);
|
// TODO: fixed in Task 7 — load cosmetics by ID from Config.DefaultLoadout after RefactorGameConfigurationToJsonb
|
||||||
if (gameConfig.DefaultDegree is not null) viewer.Degrees.Add(gameConfig.DefaultDegree);
|
var defaultSleeveId = gameConfig.Config.DefaultLoadout.SleeveId;
|
||||||
if (gameConfig.DefaultEmblem is not null) viewer.Emblems.Add(gameConfig.DefaultEmblem);
|
var defaultDegreeId = gameConfig.Config.DefaultLoadout.DegreeId;
|
||||||
if (gameConfig.DefaultMyPageBackground is not null) viewer.MyPageBackgrounds.Add(gameConfig.DefaultMyPageBackground);
|
var defaultEmblemId = gameConfig.Config.DefaultLoadout.EmblemId;
|
||||||
|
var defaultBgId = gameConfig.Config.DefaultLoadout.MyPageBackgroundId;
|
||||||
|
var defaultSleeve = await _dbContext.Set<SleeveEntry>().FindAsync(defaultSleeveId);
|
||||||
|
var defaultDegree = await _dbContext.Set<DegreeEntry>().FindAsync(defaultDegreeId);
|
||||||
|
var defaultEmblem = await _dbContext.Set<EmblemEntry>().FindAsync(defaultEmblemId);
|
||||||
|
var defaultBg = await _dbContext.Set<MyPageBackgroundEntry>().FindAsync(defaultBgId);
|
||||||
|
if (defaultSleeve is not null) viewer.Sleeves.Add(defaultSleeve);
|
||||||
|
if (defaultDegree is not null) viewer.Degrees.Add(defaultDegree);
|
||||||
|
if (defaultEmblem is not null) viewer.Emblems.Add(defaultEmblem);
|
||||||
|
if (defaultBg is not null) viewer.MyPageBackgrounds.Add(defaultBg);
|
||||||
|
|
||||||
// Grant one of each class's default leader skin. Filter out the synthetic placeholders
|
// Grant one of each class's default leader skin. Filter out the synthetic placeholders
|
||||||
// (Id=0) and dedupe — skins are many-to-many via SleeveEntryViewer-style join.
|
// (Id=0) and dedupe — skins are many-to-many via SleeveEntryViewer-style join.
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
using System.Text.Json;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.ChangeTracking;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using SVSim.Database.Common;
|
using SVSim.Database.Common;
|
||||||
using SVSim.Database.DataSeeders;
|
using SVSim.Database.DataSeeders;
|
||||||
@@ -112,6 +115,55 @@ public class SVSimDbContext : DbContext
|
|||||||
modelBuilder.Entity<PackConfigEntry>().OwnsMany(p => p.Banners);
|
modelBuilder.Entity<PackConfigEntry>().OwnsMany(p => p.Banners);
|
||||||
modelBuilder.Entity<Viewer>().OwnsMany(v => v.PackOpenCounts);
|
modelBuilder.Entity<Viewer>().OwnsMany(v => v.PackOpenCounts);
|
||||||
|
|
||||||
|
// GameConfiguration.Config: on Postgres use EF Core 8's OwnsOne+ToJson(jsonb column).
|
||||||
|
// On SQLite (tests) ToJson's WriteJson has a known NullReferenceException when owned
|
||||||
|
// collections are present — use a plain TEXT value converter instead so the same
|
||||||
|
// entity shape works for both providers without separate test models.
|
||||||
|
bool isSqlite = Database.ProviderName?.Contains("Sqlite", StringComparison.OrdinalIgnoreCase) == true;
|
||||||
|
if (isSqlite)
|
||||||
|
{
|
||||||
|
// Store as JSON text via a value converter; EF treats Config as a single column.
|
||||||
|
var configConverter = new ValueConverter<GameConfigRoot, string>(
|
||||||
|
model => JsonSerializer.Serialize(model, (JsonSerializerOptions?)null),
|
||||||
|
json => JsonSerializer.Deserialize<GameConfigRoot>(json, (JsonSerializerOptions?)null)
|
||||||
|
?? new GameConfigRoot());
|
||||||
|
|
||||||
|
// Deep-equality comparer: serialize both sides and compare strings so that
|
||||||
|
// mutations to nested properties (e.g. Config.Rotation.TsRotationId = "10015")
|
||||||
|
// are detected by EF's snapshot change tracker and written to the DB on SaveChanges.
|
||||||
|
var configComparer = new ValueComparer<GameConfigRoot>(
|
||||||
|
(a, b) => JsonSerializer.Serialize(a, (JsonSerializerOptions?)null)
|
||||||
|
== JsonSerializer.Serialize(b, (JsonSerializerOptions?)null),
|
||||||
|
v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null).GetHashCode(),
|
||||||
|
v => JsonSerializer.Deserialize<GameConfigRoot>(
|
||||||
|
JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
|
||||||
|
(JsonSerializerOptions?)null) ?? new GameConfigRoot());
|
||||||
|
|
||||||
|
modelBuilder.Entity<GameConfiguration>()
|
||||||
|
.Property(c => c.Config)
|
||||||
|
.HasColumnName("Config")
|
||||||
|
.HasConversion(configConverter, configComparer);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Production path: jsonb column with full OwnsOne tree so EF can filter/project
|
||||||
|
// into sub-properties at the DB level if needed.
|
||||||
|
modelBuilder.Entity<GameConfiguration>().OwnsOne(c => c.Config, b =>
|
||||||
|
{
|
||||||
|
b.ToJson("Config");
|
||||||
|
b.OwnsOne(r => r.DefaultGrants);
|
||||||
|
b.OwnsOne(r => r.Player);
|
||||||
|
b.OwnsOne(r => r.DefaultLoadout);
|
||||||
|
b.OwnsOne(r => r.Challenge);
|
||||||
|
b.OwnsOne(r => r.Rotation);
|
||||||
|
b.OwnsOne(r => r.PackRates, pr =>
|
||||||
|
{
|
||||||
|
pr.OwnsOne(p => p.Default);
|
||||||
|
pr.OwnsMany(p => p.PerSlot);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
new BaseDataSeeder().Seed(modelBuilder);
|
new BaseDataSeeder().Seed(modelBuilder);
|
||||||
new DefaultSettingsSeeder().Seed(modelBuilder);
|
new DefaultSettingsSeeder().Seed(modelBuilder);
|
||||||
|
|
||||||
@@ -135,4 +187,22 @@ public class SVSimDbContext : DbContext
|
|||||||
Database.Migrate();
|
Database.Migrate();
|
||||||
_logger.LogInformation("Migrations applied.");
|
_logger.LogInformation("Migrations applied.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Idempotent runtime seed for entities that can't use HasData (notably GameConfiguration
|
||||||
|
/// because of EF Core 8's HasData+OwnsOne(ToJson) jsonb limitation).
|
||||||
|
/// </summary>
|
||||||
|
public async Task EnsureSeedDataAsync()
|
||||||
|
{
|
||||||
|
if (!await GameConfigurations.AnyAsync(c => c.Id == "default"))
|
||||||
|
{
|
||||||
|
GameConfigurations.Add(new Models.GameConfiguration
|
||||||
|
{
|
||||||
|
Id = "default",
|
||||||
|
Config = new Models.GameConfigRoot(),
|
||||||
|
});
|
||||||
|
await SaveChangesAsync();
|
||||||
|
_logger.LogInformation("Seeded default GameConfiguration row.");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ public class LoadController : SVSimController
|
|||||||
MyPageBackgrounds = viewer.MyPageBackgrounds.Select(mpbg => mpbg.Id.ToString()).ToList(),
|
MyPageBackgrounds = viewer.MyPageBackgrounds.Select(mpbg => mpbg.Id.ToString()).ToList(),
|
||||||
LootBoxRegulations = new LootBoxRegulations(),
|
LootBoxRegulations = new LootBoxRegulations(),
|
||||||
GatheringInfo = new GatheringInfo(),
|
GatheringInfo = new GatheringInfo(),
|
||||||
IsBattlePassPeriod = cfg.IsBattlePassPeriod,
|
IsBattlePassPeriod = cfg.Config.Rotation.IsBattlePassPeriod,
|
||||||
// Optional per spec (load-index.md:228). We have BattlePassLevelEntry rows seeded, but
|
// Optional per spec (load-index.md:228). We have BattlePassLevelEntry rows seeded, but
|
||||||
// no per-viewer Battle Pass progression yet — emit null until that subsystem lands.
|
// no per-viewer Battle Pass progression yet — emit null until that subsystem lands.
|
||||||
BattlePassLevelInfo = null,
|
BattlePassLevelInfo = null,
|
||||||
@@ -214,8 +214,8 @@ public class LoadController : SVSimController
|
|||||||
}).ToList(),
|
}).ToList(),
|
||||||
ArenaConfig = new ArenaConfig
|
ArenaConfig = new ArenaConfig
|
||||||
{
|
{
|
||||||
UseChallengePickTwoPremiumCard = cfg.ChallengeUseTwoPickPremiumCard ? 1 : 0,
|
UseChallengePickTwoPremiumCard = cfg.Config.Challenge.UseTwoPickPremiumCard ? 1 : 0,
|
||||||
ChallengePickTwoCardSleeve = (int)cfg.ChallengeTwoPickSleeveId,
|
ChallengePickTwoCardSleeve = (int)cfg.Config.Challenge.TwoPickSleeveId,
|
||||||
},
|
},
|
||||||
ArenaInfos = await BuildArenaInfosAsync(),
|
ArenaInfos = await BuildArenaInfosAsync(),
|
||||||
RotationSets = rotationSets,
|
RotationSets = rotationSets,
|
||||||
@@ -226,7 +226,7 @@ public class LoadController : SVSimController
|
|||||||
ClassExp = classExps,
|
ClassExp = classExps,
|
||||||
RankInfo = (await _globalsRepository.GetRankInfo()).Select(ri => new RankInfo(ri)).ToList(),
|
RankInfo = (await _globalsRepository.GetRankInfo()).Select(ri => new RankInfo(ri)).ToList(),
|
||||||
DeckFormat = Format.Rotation,
|
DeckFormat = Format.Rotation,
|
||||||
CardSetIdForResourceDlView = cfg.CardSetIdForResourceDlView,
|
CardSetIdForResourceDlView = cfg.Config.Rotation.CardSetIdForResourceDlView,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ public class MyPageController : SVSimController
|
|||||||
UserMyPageSetting = new MyPageBgSetting(),
|
UserMyPageSetting = new MyPageBgSetting(),
|
||||||
},
|
},
|
||||||
BasicPuzzle = new BasicPuzzle { IsDisplayBadge = false }, // TODO(mypage-stub): viewer practice-puzzle progress
|
BasicPuzzle = new BasicPuzzle { IsDisplayBadge = false }, // TODO(mypage-stub): viewer practice-puzzle progress
|
||||||
IsBattlePassPeriod = cfg.IsBattlePassPeriod,
|
IsBattlePassPeriod = cfg.Config.Rotation.IsBattlePassPeriod,
|
||||||
SpecialCrystalInfo = new(), // TODO(mypage-stub): same shape/source as /load/index
|
SpecialCrystalInfo = new(), // TODO(mypage-stub): same shape/source as /load/index
|
||||||
// CompetitionInfo, ShopNotification, StoryNotification, GuildNotification, GatheringInfo,
|
// CompetitionInfo, ShopNotification, StoryNotification, GuildNotification, GatheringInfo,
|
||||||
// IsHiddenBossAppeared, SubBanner/SubBannerList/HomeDialogList/UserOfflineEvent/UserItemList,
|
// IsHiddenBossAppeared, SubBanner/SubBannerList/HomeDialogList/UserOfflineEvent/UserItemList,
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ public class DefaultSettings
|
|||||||
|
|
||||||
public DefaultSettings(GameConfiguration config)
|
public DefaultSettings(GameConfiguration config)
|
||||||
{
|
{
|
||||||
this.DefaultMyPageBackground = config.DefaultMyPageBackground.Id;
|
this.DefaultMyPageBackground = config.Config.DefaultLoadout.MyPageBackgroundId;
|
||||||
this.DefaultDegreeId = config.DefaultDegree.Id;
|
this.DefaultDegreeId = config.Config.DefaultLoadout.DegreeId;
|
||||||
this.DefaultEmblemId = config.DefaultEmblem.Id;
|
this.DefaultEmblemId = config.Config.DefaultLoadout.EmblemId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public DefaultSettings()
|
public DefaultSettings()
|
||||||
|
|||||||
@@ -63,8 +63,14 @@ public class Program
|
|||||||
builder.Services.AddTransient<IGlobalsRepository, GlobalsRepository>();
|
builder.Services.AddTransient<IGlobalsRepository, GlobalsRepository>();
|
||||||
builder.Services.AddTransient<IDeckRepository, DeckRepository>();
|
builder.Services.AddTransient<IDeckRepository, DeckRepository>();
|
||||||
builder.Services.AddTransient<IPackRepository, PackRepository>();
|
builder.Services.AddTransient<IPackRepository, PackRepository>();
|
||||||
|
// Scoped (not Singleton) to avoid the singleton-depends-on-scoped-DbContext lifecycle
|
||||||
|
// pitfall. Cost: one indexed single-row query per request — trivial. Restart still picks
|
||||||
|
// up DB-edit changes since each new request rebuilds the scope.
|
||||||
|
builder.Services.AddScoped<SVSim.Database.Models.GameConfigRoot>(sp =>
|
||||||
|
sp.GetRequiredService<SVSim.Database.Repositories.Globals.IGlobalsRepository>()
|
||||||
|
.GetGameConfiguration("default").GetAwaiter().GetResult().Config);
|
||||||
builder.Services.AddScoped<ICardPoolProvider, DbCardPoolProvider>();
|
builder.Services.AddScoped<ICardPoolProvider, DbCardPoolProvider>();
|
||||||
builder.Services.AddSingleton<PackOpenService>();
|
builder.Services.AddScoped<PackOpenService>();
|
||||||
builder.Services.AddSingleton<IRandom, SystemRandom>();
|
builder.Services.AddSingleton<IRandom, SystemRandom>();
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
@@ -93,6 +99,7 @@ public class Program
|
|||||||
if (dbContext.Database.IsRelational() && !app.Environment.IsEnvironment("Testing"))
|
if (dbContext.Database.IsRelational() && !app.Environment.IsEnvironment("Testing"))
|
||||||
{
|
{
|
||||||
dbContext.UpdateDatabase();
|
dbContext.UpdateDatabase();
|
||||||
|
dbContext.EnsureSeedDataAsync().GetAwaiter().GetResult();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,27 +16,26 @@ public class DbCardPoolProvider : ICardPoolProvider
|
|||||||
{
|
{
|
||||||
case PackCategory.None:
|
case PackCategory.None:
|
||||||
case PackCategory.LegendCardPack:
|
case PackCategory.LegendCardPack:
|
||||||
// Standard pack — pool comes from the card set whose id equals base_pack_id.
|
|
||||||
return _db.CardSets
|
return _db.CardSets
|
||||||
.Include(s => s.Cards)
|
|
||||||
.Where(s => s.Id == pack.BasePackId)
|
.Where(s => s.Id == pack.BasePackId)
|
||||||
.SelectMany(s => s.Cards)
|
.SelectMany(s => s.Cards)
|
||||||
|
.Where(c => !c.IsFoil)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
case PackCategory.SpecialCardPack:
|
case PackCategory.SpecialCardPack:
|
||||||
case PackCategory.LimitedSpecialCardPack:
|
case PackCategory.LimitedSpecialCardPack:
|
||||||
// Legendary-special packs pull from all rotation sets. The slot-8 forced-Legendary
|
|
||||||
// rule in PackOpenService delivers the "at least one legendary" promise.
|
|
||||||
return _db.CardSets
|
return _db.CardSets
|
||||||
.Where(s => s.IsInRotation)
|
.Where(s => s.IsInRotation)
|
||||||
.Include(s => s.Cards)
|
|
||||||
.SelectMany(s => s.Cards)
|
.SelectMany(s => s.Cards)
|
||||||
|
.Where(c => !c.IsFoil)
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// Skin / starter / leader-skin packs aren't drawn in v1 — controller rejects earlier.
|
|
||||||
return Array.Empty<ShadowverseCardEntry>();
|
return Array.Empty<ShadowverseCardEntry>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public ShadowverseCardEntry? TryGetFoilTwin(long baseCardId) =>
|
||||||
|
_db.Cards.FirstOrDefault(c => c.Id == baseCardId + 1 && c.IsFoil);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,4 +6,12 @@ namespace SVSim.EmulatedEntrypoint.Services;
|
|||||||
public interface ICardPoolProvider
|
public interface ICardPoolProvider
|
||||||
{
|
{
|
||||||
IReadOnlyList<ShadowverseCardEntry> GetPool(PackConfigEntry pack);
|
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,22 +1,29 @@
|
|||||||
using SVSim.Database.Enums;
|
using SVSim.Database.Enums;
|
||||||
using SVSim.Database.Models;
|
using SVSim.Database.Models;
|
||||||
|
using SVSim.Database.Models.Config;
|
||||||
|
|
||||||
namespace SVSim.EmulatedEntrypoint.Services;
|
namespace SVSim.EmulatedEntrypoint.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Draws cards from a pack's pool using the original Shadowverse Classic rates:
|
/// Draws cards from a pack's pool using rates from the injected <see cref="GameConfigRoot"/>'s
|
||||||
/// Slots 1-7: Bronze 67.44% / Silver 25% / Gold 6% / Legendary 1.5%
|
/// <see cref="PackRateConfig"/>. Slot rarity selection is unified through one
|
||||||
/// Slot 8: Silver 76.92% / Gold 18.46% / Legendary 4.62% (no Bronze)
|
/// <see cref="PickRarity"/> + <see cref="ResolveWeights"/> pair — what was previously a
|
||||||
/// Legendary-special packs (category 2/3, base >= 90001): slot 8 forced to Legendary.
|
/// hardcoded slot-1-7 vs slot-8 split now reads from <c>PackRateConfig.PerSlot</c>.
|
||||||
///
|
///
|
||||||
/// The 0.06% slack in slots 1-7 (rates sum to 99.94%) is folded into Bronze so cumulative
|
/// The "legendary-special slot-8 forced Legendary" rule stays in code (structural category
|
||||||
/// weights add to exactly 1.0 — any RNG roll past the Gold band lands in either Legendary or
|
/// promise, not a tunable rate).
|
||||||
/// Bronze, and we put it in Bronze to err on the player-unfriendly side of the spec.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class PackOpenService
|
public class PackOpenService
|
||||||
{
|
{
|
||||||
private const int CardsPerPack = 8;
|
private const int CardsPerPack = 8;
|
||||||
|
|
||||||
|
private readonly PackRateConfig _rates;
|
||||||
|
|
||||||
|
public PackOpenService(GameConfigRoot config)
|
||||||
|
{
|
||||||
|
_rates = config.PackRates;
|
||||||
|
}
|
||||||
|
|
||||||
public DrawResult Draw(
|
public DrawResult Draw(
|
||||||
PackConfigEntry pack,
|
PackConfigEntry pack,
|
||||||
ICardPoolProvider pools,
|
ICardPoolProvider pools,
|
||||||
@@ -45,48 +52,62 @@ public class PackOpenService
|
|||||||
{
|
{
|
||||||
for (int s = 0; s < CardsPerPack; s++)
|
for (int s = 0; s < CardsPerPack; s++)
|
||||||
{
|
{
|
||||||
|
int slotNum = s + 1; // 1-based
|
||||||
|
|
||||||
Rarity rarity;
|
Rarity rarity;
|
||||||
if (s == CardsPerPack - 1)
|
if (slotNum == CardsPerPack && isLegendarySpecial)
|
||||||
{
|
{
|
||||||
// Slot 8
|
// Structural category rule (not a tunable rate).
|
||||||
if (isLegendarySpecial)
|
rarity = Rarity.Legendary;
|
||||||
{
|
|
||||||
rarity = Rarity.Legendary;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
rarity = PickSlot8Rarity(rng);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
rarity = PickSlot1To7Rarity(rng);
|
rarity = PickRarity(rng, ResolveWeights(slotNum));
|
||||||
}
|
}
|
||||||
|
|
||||||
var card = PickCardOfRarity(rarity, poolByRarity, rng);
|
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));
|
slots.Add(new DrawnCard(card.Id, card.Rarity));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return new DrawResult(slots);
|
return new DrawResult(slots);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Rarity PickSlot1To7Rarity(IRandom rng)
|
/// <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)
|
||||||
{
|
{
|
||||||
double r = rng.NextDouble();
|
var slotKey = slotNum.ToString();
|
||||||
// Build cumulative bands in this order: Legendary -> Gold -> Silver -> Bronze (catch-all).
|
var perSlot = _rates.PerSlot.FirstOrDefault(s => s.Slot == slotKey);
|
||||||
if (r < 0.0150) return Rarity.Legendary; // 1.5%
|
return perSlot ?? _rates.Default;
|
||||||
if (r < 0.0750) return Rarity.Gold; // +6% = 7.5%
|
|
||||||
if (r < 0.3250) return Rarity.Silver; // +25% = 32.5%
|
|
||||||
return Rarity.Bronze; // remaining (~67.5%; absorbs 0.06% slack)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Rarity PickSlot8Rarity(IRandom rng)
|
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 r = rng.NextDouble();
|
||||||
// Renormalized over 32.5: Legendary 4.62%, Gold 18.46%, Silver 76.92%.
|
double cum = w.Legendary; if (r < cum) return Rarity.Legendary;
|
||||||
if (r < 0.0462) return Rarity.Legendary;
|
cum += w.Gold; if (r < cum) return Rarity.Gold;
|
||||||
if (r < 0.2308) return Rarity.Gold; // 0.0462 + 0.1846
|
cum += w.Silver; if (r < cum) return Rarity.Silver;
|
||||||
return Rarity.Silver;
|
return Rarity.Bronze;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ShadowverseCardEntry PickCardOfRarity(
|
private static ShadowverseCardEntry PickCardOfRarity(
|
||||||
@@ -94,10 +115,12 @@ public class PackOpenService
|
|||||||
Dictionary<Rarity, List<ShadowverseCardEntry>> poolByRarity,
|
Dictionary<Rarity, List<ShadowverseCardEntry>> poolByRarity,
|
||||||
IRandom rng)
|
IRandom rng)
|
||||||
{
|
{
|
||||||
// Fallback if the rolled rarity has no cards (e.g. pool has no Legendaries):
|
// Fallback if the rolled rarity has no cards: walk down (and up) through all rarities.
|
||||||
// walk down to Gold -> Silver -> Bronze. This is a safety net for sparse master data;
|
// Order: rolled rarity first, then Legendary -> Gold -> Silver -> Bronze, deduped by
|
||||||
// healthy production pools have all four rarities.
|
// LINQ Distinct. This handles both "no Legendaries" (fall down) and sparse pools that
|
||||||
Rarity[] fallback = { rarity, Rarity.Gold, Rarity.Silver, Rarity.Bronze };
|
// 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)
|
foreach (var r in fallback)
|
||||||
{
|
{
|
||||||
if (poolByRarity.TryGetValue(r, out var list) && list.Count > 0)
|
if (poolByRarity.TryGetValue(r, out var list) && list.Count > 0)
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
|
|||||||
using var scope = host.Services.CreateScope();
|
using var scope = host.Services.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
db.Database.EnsureCreated();
|
db.Database.EnsureCreated();
|
||||||
|
db.EnsureSeedDataAsync().GetAwaiter().GetResult();
|
||||||
|
|
||||||
// Seed a minimal card set so card-pool tests can resolve a non-empty pool without
|
// Seed a minimal card set so card-pool tests can resolve a non-empty pool without
|
||||||
// requiring the full CardImporter tool or a cards.json file. The set is marked
|
// requiring the full CardImporter tool or a cards.json file. The set is marked
|
||||||
|
|||||||
75
SVSim.UnitTests/Models/GameConfigurationJsonbTests.cs
Normal file
75
SVSim.UnitTests/Models/GameConfigurationJsonbTests.cs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using SVSim.Database;
|
||||||
|
using SVSim.Database.Models;
|
||||||
|
using SVSim.UnitTests.Infrastructure;
|
||||||
|
|
||||||
|
namespace SVSim.UnitTests.Models;
|
||||||
|
|
||||||
|
public class GameConfigurationJsonbTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public async Task DefaultSeed_populates_canonical_GameConfigRoot_defaults()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
using var scope = factory.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
|
||||||
|
var cfg = await db.GameConfigurations.FirstOrDefaultAsync(c => c.Id == "default");
|
||||||
|
|
||||||
|
Assert.That(cfg, Is.Not.Null, "default GameConfiguration row must exist (seeded via EnsureSeedDataAsync)");
|
||||||
|
Assert.That(cfg!.Config, Is.Not.Null, "Config must round-trip to non-null GameConfigRoot");
|
||||||
|
Assert.That(cfg.Config.DefaultGrants.Crystals, Is.EqualTo(50000UL), "pre-refactor default");
|
||||||
|
Assert.That(cfg.Config.DefaultGrants.Rupees, Is.EqualTo(50000UL), "pre-refactor default");
|
||||||
|
Assert.That(cfg.Config.DefaultGrants.Ether, Is.EqualTo(50000UL), "pre-refactor default");
|
||||||
|
Assert.That(cfg.Config.Player.MaxFriends, Is.EqualTo(20), "pre-refactor default");
|
||||||
|
Assert.That(cfg.Config.DefaultLoadout.SleeveId, Is.EqualTo(3000011), "pre-refactor default sleeve");
|
||||||
|
Assert.That(cfg.Config.PackRates.AnimatedRate, Is.EqualTo(0.08).Within(1e-9), "SV Classic default");
|
||||||
|
Assert.That(cfg.Config.PackRates.Default.Bronze, Is.EqualTo(0.6744).Within(1e-9));
|
||||||
|
// PerSlot is now a List<SlotRarityWeights> keyed by Slot string (see Task 5 deviation note).
|
||||||
|
var slot8 = cfg.Config.PackRates.PerSlot.FirstOrDefault(s => s.Slot == "8");
|
||||||
|
Assert.That(slot8, Is.Not.Null, "slot-8 default entry must be present");
|
||||||
|
Assert.That(slot8!.Silver, Is.EqualTo(0.7692).Within(1e-9));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Mutation_then_save_then_reload_round_trips_through_jsonb()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
|
||||||
|
using (var scope = factory.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
var cfg = await db.GameConfigurations.FirstAsync(c => c.Id == "default");
|
||||||
|
cfg.Config.Rotation.TsRotationId = "99999";
|
||||||
|
cfg.Config.PackRates.AnimatedRate = 0.42;
|
||||||
|
db.Entry(cfg).Property(c => c.Config).IsModified = true;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var scope = factory.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
var cfg = await db.GameConfigurations.FirstAsync(c => c.Id == "default");
|
||||||
|
Assert.That(cfg.Config.Rotation.TsRotationId, Is.EqualTo("99999"));
|
||||||
|
Assert.That(cfg.Config.PackRates.AnimatedRate, Is.EqualTo(0.42).Within(1e-9));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GlobalsImporter_updates_Rotation_without_clobbering_other_subconfigs()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
await factory.SeedGlobalsAsync(); // imports load-index which has ts_rotation_id="10015"
|
||||||
|
|
||||||
|
using var scope = factory.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
var cfg = await db.GameConfigurations.FirstAsync(c => c.Id == "default");
|
||||||
|
|
||||||
|
Assert.That(cfg.Config.Rotation.TsRotationId, Is.EqualTo("10015"),
|
||||||
|
"GlobalsImporter should set Rotation.TsRotationId from the prod capture.");
|
||||||
|
// PackRates is NOT in the load-index capture; must keep the seeded default unchanged.
|
||||||
|
Assert.That(cfg.Config.PackRates.AnimatedRate, Is.EqualTo(0.08).Within(1e-9),
|
||||||
|
"GlobalsImporter must not clobber PackRates while updating Rotation.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -213,9 +213,9 @@ public class GlobalsRepositoryTests
|
|||||||
using var _ = factory;
|
using var _ = factory;
|
||||||
var cfg = await repo.GetGameConfiguration("default");
|
var cfg = await repo.GetGameConfiguration("default");
|
||||||
Assert.That(cfg, Is.Not.Null);
|
Assert.That(cfg, Is.Not.Null);
|
||||||
Assert.That(cfg.TsRotationId, Is.EqualTo("10015"),
|
Assert.That(cfg.Config.Rotation.TsRotationId, Is.EqualTo("10015"),
|
||||||
"GlobalsImporter should overwrite the migration's empty-string default with the capture value.");
|
"GlobalsImporter should overwrite the migration's empty-string default with the capture value.");
|
||||||
Assert.That(cfg.IsBattlePassPeriod, Is.True,
|
Assert.That(cfg.Config.Rotation.IsBattlePassPeriod, Is.True,
|
||||||
"Prod sends bool true for is_battle_pass_period; capture should overwrite the migration default of false.");
|
"Prod sends bool true for is_battle_pass_period; capture should overwrite the migration default of false.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,4 +64,85 @@ public class DbCardPoolProviderTests
|
|||||||
|
|
||||||
Assert.That(pool, Is.Empty);
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using SVSim.Database.Enums;
|
using SVSim.Database.Enums;
|
||||||
using SVSim.Database.Models;
|
using SVSim.Database.Models;
|
||||||
|
using SVSim.Database.Models.Config;
|
||||||
using SVSim.EmulatedEntrypoint.Services;
|
using SVSim.EmulatedEntrypoint.Services;
|
||||||
|
|
||||||
namespace SVSim.UnitTests.Services;
|
namespace SVSim.UnitTests.Services;
|
||||||
@@ -21,6 +22,7 @@ public class PackOpenServiceTests
|
|||||||
private readonly IReadOnlyList<ShadowverseCardEntry> _cards;
|
private readonly IReadOnlyList<ShadowverseCardEntry> _cards;
|
||||||
public StubPool(IReadOnlyList<ShadowverseCardEntry> cards) { _cards = cards; }
|
public StubPool(IReadOnlyList<ShadowverseCardEntry> cards) { _cards = cards; }
|
||||||
public IReadOnlyList<ShadowverseCardEntry> GetPool(PackConfigEntry _) => _cards;
|
public IReadOnlyList<ShadowverseCardEntry> GetPool(PackConfigEntry _) => _cards;
|
||||||
|
public ShadowverseCardEntry? TryGetFoilTwin(long baseCardId) => null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<ShadowverseCardEntry> MakeFourCards() => new()
|
private static List<ShadowverseCardEntry> MakeFourCards() => new()
|
||||||
@@ -39,7 +41,8 @@ public class PackOpenServiceTests
|
|||||||
[Test]
|
[Test]
|
||||||
public void Draw_returns_eight_cards_for_one_pack()
|
public void Draw_returns_eight_cards_for_one_pack()
|
||||||
{
|
{
|
||||||
var svc = new PackOpenService();
|
var rootDefault = new GameConfigRoot(); // canonical SV Classic defaults
|
||||||
|
var svc = new PackOpenService(rootDefault);
|
||||||
var pool = new StubPool(MakeFourCards());
|
var pool = new StubPool(MakeFourCards());
|
||||||
var rng = new ScriptedRandom(0.5); // anything in Bronze/Silver range for non-slot-8
|
var rng = new ScriptedRandom(0.5); // anything in Bronze/Silver range for non-slot-8
|
||||||
|
|
||||||
@@ -51,7 +54,8 @@ public class PackOpenServiceTests
|
|||||||
[Test]
|
[Test]
|
||||||
public void Draw_slot_8_never_returns_bronze_for_standard_pack()
|
public void Draw_slot_8_never_returns_bronze_for_standard_pack()
|
||||||
{
|
{
|
||||||
var svc = new PackOpenService();
|
var rootDefault = new GameConfigRoot(); // canonical SV Classic defaults
|
||||||
|
var svc = new PackOpenService(rootDefault);
|
||||||
var pool = new StubPool(MakeFourCards());
|
var pool = new StubPool(MakeFourCards());
|
||||||
|
|
||||||
for (int trial = 0; trial < 1000; trial++)
|
for (int trial = 0; trial < 1000; trial++)
|
||||||
@@ -65,7 +69,8 @@ public class PackOpenServiceTests
|
|||||||
[Test]
|
[Test]
|
||||||
public void Draw_legendary_special_forces_slot_8_to_legendary()
|
public void Draw_legendary_special_forces_slot_8_to_legendary()
|
||||||
{
|
{
|
||||||
var svc = new PackOpenService();
|
var rootDefault = new GameConfigRoot(); // canonical SV Classic defaults
|
||||||
|
var svc = new PackOpenService(rootDefault);
|
||||||
var pool = new StubPool(MakeFourCards());
|
var pool = new StubPool(MakeFourCards());
|
||||||
var pack = new PackConfigEntry { Id = 92001, BasePackId = 90001, PackCategory = PackCategory.SpecialCardPack };
|
var pack = new PackConfigEntry { Id = 92001, BasePackId = 90001, PackCategory = PackCategory.SpecialCardPack };
|
||||||
|
|
||||||
@@ -80,7 +85,8 @@ public class PackOpenServiceTests
|
|||||||
[Test]
|
[Test]
|
||||||
public void Draw_distribution_over_10k_slots_1_to_7_matches_declared_rates_within_2_percent()
|
public void Draw_distribution_over_10k_slots_1_to_7_matches_declared_rates_within_2_percent()
|
||||||
{
|
{
|
||||||
var svc = new PackOpenService();
|
var rootDefault = new GameConfigRoot(); // canonical SV Classic defaults
|
||||||
|
var svc = new PackOpenService(rootDefault);
|
||||||
var pool = new StubPool(MakeFourCards());
|
var pool = new StubPool(MakeFourCards());
|
||||||
var counts = new Dictionary<Rarity, int>
|
var counts = new Dictionary<Rarity, int>
|
||||||
{
|
{
|
||||||
@@ -111,7 +117,8 @@ public class PackOpenServiceTests
|
|||||||
[Test]
|
[Test]
|
||||||
public void Draw_excludes_listed_card_ids()
|
public void Draw_excludes_listed_card_ids()
|
||||||
{
|
{
|
||||||
var svc = new PackOpenService();
|
var rootDefault = new GameConfigRoot(); // canonical SV Classic defaults
|
||||||
|
var svc = new PackOpenService(rootDefault);
|
||||||
// Pool with two bronze cards; exclude one — every Bronze slot must pick the other.
|
// Pool with two bronze cards; exclude one — every Bronze slot must pick the other.
|
||||||
var pool = new StubPool(new List<ShadowverseCardEntry>
|
var pool = new StubPool(new List<ShadowverseCardEntry>
|
||||||
{
|
{
|
||||||
@@ -128,4 +135,112 @@ public class PackOpenServiceTests
|
|||||||
Assert.That(c.CardId, Is.EqualTo(99), "excluded card 1 must never appear in Bronze slot");
|
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 (EF Core 8 deviation from
|
||||||
|
// the Dictionary<int, T> in the spec — see Task 5 notes).
|
||||||
|
var root = new GameConfigRoot();
|
||||||
|
root.PackRates.PerSlot.Add(new SlotRarityWeights
|
||||||
|
{
|
||||||
|
Slot = "3",
|
||||||
|
Bronze = 0, Silver = 0, Gold = 0, Legendary = 1.0,
|
||||||
|
});
|
||||||
|
|
||||||
|
var svc = new PackOpenService(root);
|
||||||
|
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 root = new GameConfigRoot(); // default AnimatedRate = 0.08
|
||||||
|
var svc = new PackOpenService(root);
|
||||||
|
|
||||||
|
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 root = new GameConfigRoot();
|
||||||
|
root.PackRates.AnimatedRate = 1.0;
|
||||||
|
var svc = new PackOpenService(root);
|
||||||
|
|
||||||
|
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 root = new GameConfigRoot();
|
||||||
|
root.PackRates.AnimatedRate = 1.0; // every slot upgrades
|
||||||
|
var svc = new PackOpenService(root);
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user