refactor(bp): flatten BattlePassLevelEntry — drop misnamed RewardData jsonb

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-05-26 21:57:47 -04:00
parent 141f34f817
commit 95b8f39ea5
9 changed files with 3134 additions and 541 deletions

View File

@@ -1,702 +1,402 @@
[ [
{ {
"id": 1, "level": 1,
"reward_data": { "required_point": 0
"level": "1",
"required_point": "0"
}
}, },
{ {
"id": 2, "level": 2,
"reward_data": { "required_point": 500
"level": "2",
"required_point": "500"
}
}, },
{ {
"id": 3, "level": 3,
"reward_data": { "required_point": 1000
"level": "3",
"required_point": "1000"
}
}, },
{ {
"id": 4, "level": 4,
"reward_data": { "required_point": 1500
"level": "4",
"required_point": "1500"
}
}, },
{ {
"id": 5, "level": 5,
"reward_data": { "required_point": 2000
"level": "5",
"required_point": "2000"
}
}, },
{ {
"id": 6, "level": 6,
"reward_data": { "required_point": 2500
"level": "6",
"required_point": "2500"
}
}, },
{ {
"id": 7, "level": 7,
"reward_data": { "required_point": 3000
"level": "7",
"required_point": "3000"
}
}, },
{ {
"id": 8, "level": 8,
"reward_data": { "required_point": 3500
"level": "8",
"required_point": "3500"
}
}, },
{ {
"id": 9, "level": 9,
"reward_data": { "required_point": 4000
"level": "9",
"required_point": "4000"
}
}, },
{ {
"id": 10, "level": 10,
"reward_data": { "required_point": 4500
"level": "10",
"required_point": "4500"
}
}, },
{ {
"id": 11, "level": 11,
"reward_data": { "required_point": 5000
"level": "11",
"required_point": "5000"
}
}, },
{ {
"id": 12, "level": 12,
"reward_data": { "required_point": 5500
"level": "12",
"required_point": "5500"
}
}, },
{ {
"id": 13, "level": 13,
"reward_data": { "required_point": 6000
"level": "13",
"required_point": "6000"
}
}, },
{ {
"id": 14, "level": 14,
"reward_data": { "required_point": 6500
"level": "14",
"required_point": "6500"
}
}, },
{ {
"id": 15, "level": 15,
"reward_data": { "required_point": 7000
"level": "15",
"required_point": "7000"
}
}, },
{ {
"id": 16, "level": 16,
"reward_data": { "required_point": 7500
"level": "16",
"required_point": "7500"
}
}, },
{ {
"id": 17, "level": 17,
"reward_data": { "required_point": 8000
"level": "17",
"required_point": "8000"
}
}, },
{ {
"id": 18, "level": 18,
"reward_data": { "required_point": 8500
"level": "18",
"required_point": "8500"
}
}, },
{ {
"id": 19, "level": 19,
"reward_data": { "required_point": 9000
"level": "19",
"required_point": "9000"
}
}, },
{ {
"id": 20, "level": 20,
"reward_data": { "required_point": 9500
"level": "20",
"required_point": "9500"
}
}, },
{ {
"id": 21, "level": 21,
"reward_data": { "required_point": 10000
"level": "21",
"required_point": "10000"
}
}, },
{ {
"id": 22, "level": 22,
"reward_data": { "required_point": 10500
"level": "22",
"required_point": "10500"
}
}, },
{ {
"id": 23, "level": 23,
"reward_data": { "required_point": 11000
"level": "23",
"required_point": "11000"
}
}, },
{ {
"id": 24, "level": 24,
"reward_data": { "required_point": 11500
"level": "24",
"required_point": "11500"
}
}, },
{ {
"id": 25, "level": 25,
"reward_data": { "required_point": 12000
"level": "25",
"required_point": "12000"
}
}, },
{ {
"id": 26, "level": 26,
"reward_data": { "required_point": 12500
"level": "26",
"required_point": "12500"
}
}, },
{ {
"id": 27, "level": 27,
"reward_data": { "required_point": 13000
"level": "27",
"required_point": "13000"
}
}, },
{ {
"id": 28, "level": 28,
"reward_data": { "required_point": 13500
"level": "28",
"required_point": "13500"
}
}, },
{ {
"id": 29, "level": 29,
"reward_data": { "required_point": 14000
"level": "29",
"required_point": "14000"
}
}, },
{ {
"id": 30, "level": 30,
"reward_data": { "required_point": 14500
"level": "30",
"required_point": "14500"
}
}, },
{ {
"id": 31, "level": 31,
"reward_data": { "required_point": 15000
"level": "31",
"required_point": "15000"
}
}, },
{ {
"id": 32, "level": 32,
"reward_data": { "required_point": 15500
"level": "32",
"required_point": "15500"
}
}, },
{ {
"id": 33, "level": 33,
"reward_data": { "required_point": 16000
"level": "33",
"required_point": "16000"
}
}, },
{ {
"id": 34, "level": 34,
"reward_data": { "required_point": 16500
"level": "34",
"required_point": "16500"
}
}, },
{ {
"id": 35, "level": 35,
"reward_data": { "required_point": 17000
"level": "35",
"required_point": "17000"
}
}, },
{ {
"id": 36, "level": 36,
"reward_data": { "required_point": 17500
"level": "36",
"required_point": "17500"
}
}, },
{ {
"id": 37, "level": 37,
"reward_data": { "required_point": 18000
"level": "37",
"required_point": "18000"
}
}, },
{ {
"id": 38, "level": 38,
"reward_data": { "required_point": 18500
"level": "38",
"required_point": "18500"
}
}, },
{ {
"id": 39, "level": 39,
"reward_data": { "required_point": 19000
"level": "39",
"required_point": "19000"
}
}, },
{ {
"id": 40, "level": 40,
"reward_data": { "required_point": 19500
"level": "40",
"required_point": "19500"
}
}, },
{ {
"id": 41, "level": 41,
"reward_data": { "required_point": 20000
"level": "41",
"required_point": "20000"
}
}, },
{ {
"id": 42, "level": 42,
"reward_data": { "required_point": 20500
"level": "42",
"required_point": "20500"
}
}, },
{ {
"id": 43, "level": 43,
"reward_data": { "required_point": 21000
"level": "43",
"required_point": "21000"
}
}, },
{ {
"id": 44, "level": 44,
"reward_data": { "required_point": 21500
"level": "44",
"required_point": "21500"
}
}, },
{ {
"id": 45, "level": 45,
"reward_data": { "required_point": 22000
"level": "45",
"required_point": "22000"
}
}, },
{ {
"id": 46, "level": 46,
"reward_data": { "required_point": 22500
"level": "46",
"required_point": "22500"
}
}, },
{ {
"id": 47, "level": 47,
"reward_data": { "required_point": 23000
"level": "47",
"required_point": "23000"
}
}, },
{ {
"id": 48, "level": 48,
"reward_data": { "required_point": 23500
"level": "48",
"required_point": "23500"
}
}, },
{ {
"id": 49, "level": 49,
"reward_data": { "required_point": 24000
"level": "49",
"required_point": "24000"
}
}, },
{ {
"id": 50, "level": 50,
"reward_data": { "required_point": 24500
"level": "50",
"required_point": "24500"
}
}, },
{ {
"id": 51, "level": 51,
"reward_data": { "required_point": 25000
"level": "51",
"required_point": "25000"
}
}, },
{ {
"id": 52, "level": 52,
"reward_data": { "required_point": 25500
"level": "52",
"required_point": "25500"
}
}, },
{ {
"id": 53, "level": 53,
"reward_data": { "required_point": 26000
"level": "53",
"required_point": "26000"
}
}, },
{ {
"id": 54, "level": 54,
"reward_data": { "required_point": 26500
"level": "54",
"required_point": "26500"
}
}, },
{ {
"id": 55, "level": 55,
"reward_data": { "required_point": 27000
"level": "55",
"required_point": "27000"
}
}, },
{ {
"id": 56, "level": 56,
"reward_data": { "required_point": 27500
"level": "56",
"required_point": "27500"
}
}, },
{ {
"id": 57, "level": 57,
"reward_data": { "required_point": 28000
"level": "57",
"required_point": "28000"
}
}, },
{ {
"id": 58, "level": 58,
"reward_data": { "required_point": 28500
"level": "58",
"required_point": "28500"
}
}, },
{ {
"id": 59, "level": 59,
"reward_data": { "required_point": 29000
"level": "59",
"required_point": "29000"
}
}, },
{ {
"id": 60, "level": 60,
"reward_data": { "required_point": 29500
"level": "60",
"required_point": "29500"
}
}, },
{ {
"id": 61, "level": 61,
"reward_data": { "required_point": 30000
"level": "61",
"required_point": "30000"
}
}, },
{ {
"id": 62, "level": 62,
"reward_data": { "required_point": 30500
"level": "62",
"required_point": "30500"
}
}, },
{ {
"id": 63, "level": 63,
"reward_data": { "required_point": 31000
"level": "63",
"required_point": "31000"
}
}, },
{ {
"id": 64, "level": 64,
"reward_data": { "required_point": 31500
"level": "64",
"required_point": "31500"
}
}, },
{ {
"id": 65, "level": 65,
"reward_data": { "required_point": 32000
"level": "65",
"required_point": "32000"
}
}, },
{ {
"id": 66, "level": 66,
"reward_data": { "required_point": 32500
"level": "66",
"required_point": "32500"
}
}, },
{ {
"id": 67, "level": 67,
"reward_data": { "required_point": 33000
"level": "67",
"required_point": "33000"
}
}, },
{ {
"id": 68, "level": 68,
"reward_data": { "required_point": 33500
"level": "68",
"required_point": "33500"
}
}, },
{ {
"id": 69, "level": 69,
"reward_data": { "required_point": 34000
"level": "69",
"required_point": "34000"
}
}, },
{ {
"id": 70, "level": 70,
"reward_data": { "required_point": 34500
"level": "70",
"required_point": "34500"
}
}, },
{ {
"id": 71, "level": 71,
"reward_data": { "required_point": 35000
"level": "71",
"required_point": "35000"
}
}, },
{ {
"id": 72, "level": 72,
"reward_data": { "required_point": 35500
"level": "72",
"required_point": "35500"
}
}, },
{ {
"id": 73, "level": 73,
"reward_data": { "required_point": 36000
"level": "73",
"required_point": "36000"
}
}, },
{ {
"id": 74, "level": 74,
"reward_data": { "required_point": 36500
"level": "74",
"required_point": "36500"
}
}, },
{ {
"id": 75, "level": 75,
"reward_data": { "required_point": 37000
"level": "75",
"required_point": "37000"
}
}, },
{ {
"id": 76, "level": 76,
"reward_data": { "required_point": 37500
"level": "76",
"required_point": "37500"
}
}, },
{ {
"id": 77, "level": 77,
"reward_data": { "required_point": 38000
"level": "77",
"required_point": "38000"
}
}, },
{ {
"id": 78, "level": 78,
"reward_data": { "required_point": 38500
"level": "78",
"required_point": "38500"
}
}, },
{ {
"id": 79, "level": 79,
"reward_data": { "required_point": 39000
"level": "79",
"required_point": "39000"
}
}, },
{ {
"id": 80, "level": 80,
"reward_data": { "required_point": 39500
"level": "80",
"required_point": "39500"
}
}, },
{ {
"id": 81, "level": 81,
"reward_data": { "required_point": 40000
"level": "81",
"required_point": "40000"
}
}, },
{ {
"id": 82, "level": 82,
"reward_data": { "required_point": 40500
"level": "82",
"required_point": "40500"
}
}, },
{ {
"id": 83, "level": 83,
"reward_data": { "required_point": 41000
"level": "83",
"required_point": "41000"
}
}, },
{ {
"id": 84, "level": 84,
"reward_data": { "required_point": 41500
"level": "84",
"required_point": "41500"
}
}, },
{ {
"id": 85, "level": 85,
"reward_data": { "required_point": 42000
"level": "85",
"required_point": "42000"
}
}, },
{ {
"id": 86, "level": 86,
"reward_data": { "required_point": 42500
"level": "86",
"required_point": "42500"
}
}, },
{ {
"id": 87, "level": 87,
"reward_data": { "required_point": 43000
"level": "87",
"required_point": "43000"
}
}, },
{ {
"id": 88, "level": 88,
"reward_data": { "required_point": 43500
"level": "88",
"required_point": "43500"
}
}, },
{ {
"id": 89, "level": 89,
"reward_data": { "required_point": 44000
"level": "89",
"required_point": "44000"
}
}, },
{ {
"id": 90, "level": 90,
"reward_data": { "required_point": 44500
"level": "90",
"required_point": "44500"
}
}, },
{ {
"id": 91, "level": 91,
"reward_data": { "required_point": 45000
"level": "91",
"required_point": "45000"
}
}, },
{ {
"id": 92, "level": 92,
"reward_data": { "required_point": 45500
"level": "92",
"required_point": "45500"
}
}, },
{ {
"id": 93, "level": 93,
"reward_data": { "required_point": 46000
"level": "93",
"required_point": "46000"
}
}, },
{ {
"id": 94, "level": 94,
"reward_data": { "required_point": 46500
"level": "94",
"required_point": "46500"
}
}, },
{ {
"id": 95, "level": 95,
"reward_data": { "required_point": 47000
"level": "95",
"required_point": "47000"
}
}, },
{ {
"id": 96, "level": 96,
"reward_data": { "required_point": 47500
"level": "96",
"required_point": "47500"
}
}, },
{ {
"id": 97, "level": 97,
"reward_data": { "required_point": 48000
"level": "97",
"required_point": "48000"
}
}, },
{ {
"id": 98, "level": 98,
"reward_data": { "required_point": 48500
"level": "98",
"required_point": "48500"
}
}, },
{ {
"id": 99, "level": 99,
"reward_data": { "required_point": 49000
"level": "99",
"required_point": "49000"
}
}, },
{ {
"id": 100, "level": 100,
"reward_data": { "required_point": 49500
"level": "100",
"required_point": "49500"
}
} }
] ]

View File

@@ -1,4 +1,3 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using SVSim.Bootstrap.Models.Seed; using SVSim.Bootstrap.Models.Seed;
using SVSim.Database; using SVSim.Database;
@@ -8,7 +7,7 @@ namespace SVSim.Bootstrap.Importers;
/// <summary> /// <summary>
/// Idempotent upsert of battle-pass level rows from <c>seeds/battle-pass-levels.json</c>. /// Idempotent upsert of battle-pass level rows from <c>seeds/battle-pass-levels.json</c>.
/// Per-level <c>reward_data</c> blob preserved verbatim (shape varies per level). /// Curve is global; rows missing from the seed are LEFT INTACT.
/// </summary> /// </summary>
public class BattlePassImporter public class BattlePassImporter
{ {
@@ -21,12 +20,10 @@ public class BattlePassImporter
int created = 0, updated = 0; int created = 0, updated = 0;
foreach (var s in seed) foreach (var s in seed)
{ {
if (s.Id == 0) continue; if (s.Level == 0) continue;
var entry = existing.TryGetValue(s.Id, out var ex) ? ex : new BattlePassLevelEntry { Id = s.Id }; var entry = existing.TryGetValue(s.Level, out var ex) ? ex : new BattlePassLevelEntry { Level = s.Level };
entry.RewardData = s.RewardData.ValueKind == JsonValueKind.Undefined entry.RequiredPoint = s.RequiredPoint;
? "{}" if (ex is null) { context.BattlePassLevels.Add(entry); existing[s.Level] = entry; created++; }
: JsonSerializer.Serialize(s.RewardData);
if (ex is null) { context.BattlePassLevels.Add(entry); existing[s.Id] = entry; created++; }
else updated++; else updated++;
} }

View File

@@ -1,11 +1,10 @@
using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace SVSim.Bootstrap.Models.Seed; namespace SVSim.Bootstrap.Models.Seed;
/// <summary>Mirrors <c>seeds/battle-pass-levels.json</c>. <c>reward_data</c> preserved verbatim.</summary> /// <summary>Mirrors a single entry in <c>seeds/battle-pass-levels.json</c>.</summary>
public sealed class BattlePassLevelSeed public sealed class BattlePassLevelSeed
{ {
[JsonPropertyName("id")] public int Id { get; set; } [JsonPropertyName("level")] public int Level { get; set; }
[JsonPropertyName("reward_data")] public JsonElement RewardData { get; set; } [JsonPropertyName("required_point")] public int RequiredPoint { get; set; }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SVSim.Database.Migrations
{
/// <inheritdoc />
public partial class RefactorBattlePassLevels : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "RewardData", table: "BattlePassLevels");
migrationBuilder.AddColumn<int>(
name: "RequiredPoint", table: "BattlePassLevels",
type: "integer", nullable: false, defaultValue: 0);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "RequiredPoint", table: "BattlePassLevels");
migrationBuilder.AddColumn<string>(
name: "RewardData", table: "BattlePassLevels",
type: "jsonb", nullable: false, defaultValue: "{}");
}
}
}

View File

@@ -508,9 +508,8 @@ namespace SVSim.Database.Migrations
b.Property<int>("Level") b.Property<int>("Level")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<string>("RewardData") b.Property<int>("RequiredPoint")
.IsRequired() .HasColumnType("integer");
.HasColumnType("jsonb");
b.HasKey("Id"); b.HasKey("Id");

View File

@@ -1,16 +1,13 @@
using System.ComponentModel.DataAnnotations.Schema;
using SVSim.Database.Common; using SVSim.Database.Common;
namespace SVSim.Database.Models; namespace SVSim.Database.Models;
/// <summary> /// <summary>
/// One battle pass level (1-100). RewardData jsonb holds the per-level reward blob from /// One battle pass level (1..100). Mirrors a single entry in /load/index.battle_pass_level_info.
/// /load/index data.battle_pass_level_info[level]. Shape varies per level so we preserve verbatim. /// Curve is global, immutable per deploy; cached by IBattlePassService.
/// </summary> /// </summary>
public class BattlePassLevelEntry : BaseEntity<int> public class BattlePassLevelEntry : BaseEntity<int>
{ {
public int Level { get => Id; set => Id = value; } public int Level { get => Id; set => Id = value; }
public int RequiredPoint { get; set; }
[Column(TypeName = "jsonb")]
public string RewardData { get; set; } = "{}";
} }

View File

@@ -0,0 +1,83 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SVSim.Bootstrap.Importers;
using SVSim.Database;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Importers;
public class BattlePassImporterTests
{
private static string SeedDir => Path.Combine(AppContext.BaseDirectory, "Data", "seeds");
[Test]
public async Task Imports_level_curve_from_seed_file()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
await new BattlePassImporter().ImportAsync(db, SeedDir);
var levels = await db.BattlePassLevels.OrderBy(e => e.Level).ToListAsync();
Assert.That(levels.Count, Is.EqualTo(100), "seed must contain 100 levels");
Assert.That(levels[0].Level, Is.EqualTo(1));
Assert.That(levels[0].RequiredPoint, Is.EqualTo(0));
Assert.That(levels[1].Level, Is.EqualTo(2));
Assert.That(levels[1].RequiredPoint, Is.EqualTo(500));
}
[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 BattlePassImporter().ImportAsync(db, SeedDir);
int before = await db.BattlePassLevels.CountAsync();
await new BattlePassImporter().ImportAsync(db, SeedDir);
int after = await db.BattlePassLevels.CountAsync();
Assert.That(after, Is.EqualTo(before));
}
[Test]
public async Task Leaves_existing_rows_untouched_when_missing_from_seed()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
db.BattlePassLevels.Add(new SVSim.Database.Models.BattlePassLevelEntry
{
Level = 999, RequiredPoint = 12345,
});
await db.SaveChangesAsync();
await new BattlePassImporter().ImportAsync(db, SeedDir);
var legacy = await db.BattlePassLevels.FindAsync(999);
Assert.That(legacy, Is.Not.Null, "seed-missing row must be left intact");
Assert.That(legacy!.RequiredPoint, Is.EqualTo(12345));
}
[Test]
public async Task Empty_seed_file_is_no_op()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
string tmp = Path.Combine(Path.GetTempPath(), $"seed-{Guid.NewGuid()}");
Directory.CreateDirectory(tmp);
try
{
File.WriteAllText(Path.Combine(tmp, "battle-pass-levels.json"), "[]");
await new BattlePassImporter().ImportAsync(db, tmp);
int count = await db.BattlePassLevels.CountAsync();
Assert.That(count, Is.EqualTo(0));
}
finally { Directory.Delete(tmp, true); }
}
}

View File

@@ -7,11 +7,11 @@ using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Importers; namespace SVSim.UnitTests.Importers;
/// <summary> /// <summary>
/// Happy-path coverage for the 7 load-index importer classes introduced in Stage 9B /// Happy-path coverage for the load-index importer classes introduced in Stage 9B
/// (RotationConfig, MyRotation, AvatarAbility, ArenaSeason, BattlePass, DailyLoginBonus, /// (RotationConfig, MyRotation, AvatarAbility, ArenaSeason, DailyLoginBonus,
/// PreReleaseInfo). Each test instantiates the importer in isolation and verifies it inserts /// PreReleaseInfo). Each test instantiates the importer in isolation and verifies it inserts
/// rows from the corresponding seed file under <c>Data/seeds/</c>. Idempotency is spot-checked /// rows from the corresponding seed file under <c>Data/seeds/</c>.
/// in one test (BattlePass) to avoid duplicating the canonical 4-test set per importer. /// Idempotency, edge cases, and per-importer detail tests live in dedicated *ImporterTests files (e.g. BattlePassImporterTests).
/// </summary> /// </summary>
public class LoadIndexImporterTests public class LoadIndexImporterTests
{ {
@@ -73,22 +73,6 @@ public class LoadIndexImporterTests
Assert.That(row!.FormatInfo, Is.Not.EqualTo("{}"), "format_info blob must be populated from seed"); Assert.That(row!.FormatInfo, Is.Not.EqualTo("{}"), "format_info blob must be populated from seed");
} }
[Test]
public async Task BattlePassImporter_writes_levels_and_is_idempotent()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
await new BattlePassImporter().ImportAsync(db, SeedDir);
int after1 = await db.BattlePassLevels.CountAsync();
await new BattlePassImporter().ImportAsync(db, SeedDir);
int after2 = await db.BattlePassLevels.CountAsync();
Assert.That(after1, Is.GreaterThan(0), "battle-pass-levels.json must produce rows");
Assert.That(after2, Is.EqualTo(after1), "rerun must be idempotent (no new rows)");
}
[Test] [Test]
public async Task DailyLoginBonusImporter_writes_bonus_rows() public async Task DailyLoginBonusImporter_writes_bonus_rows()
{ {