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

View File

@@ -1,4 +1,3 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using SVSim.Bootstrap.Models.Seed;
using SVSim.Database;
@@ -8,7 +7,7 @@ namespace SVSim.Bootstrap.Importers;
/// <summary>
/// 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>
public class BattlePassImporter
{
@@ -21,12 +20,10 @@ public class BattlePassImporter
int created = 0, updated = 0;
foreach (var s in seed)
{
if (s.Id == 0) continue;
var entry = existing.TryGetValue(s.Id, out var ex) ? ex : new BattlePassLevelEntry { Id = s.Id };
entry.RewardData = s.RewardData.ValueKind == JsonValueKind.Undefined
? "{}"
: JsonSerializer.Serialize(s.RewardData);
if (ex is null) { context.BattlePassLevels.Add(entry); existing[s.Id] = entry; created++; }
if (s.Level == 0) continue;
var entry = existing.TryGetValue(s.Level, out var ex) ? ex : new BattlePassLevelEntry { Level = s.Level };
entry.RequiredPoint = s.RequiredPoint;
if (ex is null) { context.BattlePassLevels.Add(entry); existing[s.Level] = entry; created++; }
else updated++;
}

View File

@@ -1,11 +1,10 @@
using System.Text.Json;
using System.Text.Json.Serialization;
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
{
[JsonPropertyName("id")] public int Id { get; set; }
[JsonPropertyName("reward_data")] public JsonElement RewardData { get; set; }
[JsonPropertyName("level")] public int Level { 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")
.HasColumnType("integer");
b.Property<string>("RewardData")
.IsRequired()
.HasColumnType("jsonb");
b.Property<int>("RequiredPoint")
.HasColumnType("integer");
b.HasKey("Id");

View File

@@ -1,16 +1,13 @@
using System.ComponentModel.DataAnnotations.Schema;
using SVSim.Database.Common;
namespace SVSim.Database.Models;
/// <summary>
/// One battle pass level (1-100). RewardData jsonb holds the per-level reward blob from
/// /load/index data.battle_pass_level_info[level]. Shape varies per level so we preserve verbatim.
/// One battle pass level (1..100). Mirrors a single entry in /load/index.battle_pass_level_info.
/// Curve is global, immutable per deploy; cached by IBattlePassService.
/// </summary>
public class BattlePassLevelEntry : BaseEntity<int>
{
public int Level { get => Id; set => Id = value; }
[Column(TypeName = "jsonb")]
public string RewardData { get; set; } = "{}";
public int RequiredPoint { 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;
/// <summary>
/// Happy-path coverage for the 7 load-index importer classes introduced in Stage 9B
/// (RotationConfig, MyRotation, AvatarAbility, ArenaSeason, BattlePass, DailyLoginBonus,
/// Happy-path coverage for the load-index importer classes introduced in Stage 9B
/// (RotationConfig, MyRotation, AvatarAbility, ArenaSeason, DailyLoginBonus,
/// 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
/// in one test (BattlePass) to avoid duplicating the canonical 4-test set per importer.
/// rows from the corresponding seed file under <c>Data/seeds/</c>.
/// Idempotency, edge cases, and per-importer detail tests live in dedicated *ImporterTests files (e.g. BattlePassImporterTests).
/// </summary>
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");
}
[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]
public async Task DailyLoginBonusImporter_writes_bonus_rows()
{