feat(items): catalog import with Type + ThumbnailPath columns

ItemEntry gains Type (client item_type enum, 1=challenge, 2=card-pack
ticket, 3=premium orb, 4=colosseum, 5=orb piece, 6=skin/event ticket,
7=other) and ThumbnailPath. ItemImporter mirrors PaymentItemImporter
shape: find-or-create per item_id, save once, idempotent. Wired into
Bootstrap.Program and SVSimTestFactory.SeedGlobalsAsync. Unblocks
/item_purchase/info (filters card-pack tickets by Type==2) and any
reward grant of UserGoodsType.Item, which previously threw because
the catalog was empty.

466 tests pass (was 461).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-05-27 21:44:24 -04:00
parent 529fd13668
commit 6a03ff1bf6
10 changed files with 3858 additions and 0 deletions

View File

@@ -0,0 +1,362 @@
[
{
"item_id": 1,
"name": "Challenge Ticket",
"type": 1,
"thumbnail_path": "ticket_1"
},
{
"item_id": 2,
"name": "Grand Prix Ticket",
"type": 4,
"thumbnail_path": "ticket_colosseum"
},
{
"item_id": 1000,
"name": "Seer's Globe",
"type": 3,
"thumbnail_path": "thumbnail_orb"
},
{
"item_id": 1001,
"name": "Seer's Globe Shards",
"type": 5,
"thumbnail_path": "thumbnail_orb_piece"
},
{
"item_id": 2001,
"name": "Umamusume Bingo Ticket",
"type": 6,
"thumbnail_path": "ticket_2001"
},
{
"item_id": 2002,
"name": "Chiikawa Bingo Ticket",
"type": 6,
"thumbnail_path": "ticket_2002"
},
{
"item_id": 2003,
"name": "7th Anniversary Ticket",
"type": 6,
"thumbnail_path": "ticket_2003"
},
{
"item_id": 2004,
"name": "Fennie Bingo Ticket",
"type": 6,
"thumbnail_path": "ticket_2004"
},
{
"item_id": 10001,
"name": "Classic Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10001"
},
{
"item_id": 10002,
"name": "Darkness Evolved Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10002"
},
{
"item_id": 10003,
"name": "Rise of Bahamut Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10003"
},
{
"item_id": 10004,
"name": "Tempest of the Gods Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10004"
},
{
"item_id": 10005,
"name": "Wonderland Dreams Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10005"
},
{
"item_id": 10006,
"name": "Starforged Legends Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10006"
},
{
"item_id": 10007,
"name": "Chronogenesis Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10007"
},
{
"item_id": 10008,
"name": "Dawnbreak, Nightedge Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10008"
},
{
"item_id": 10009,
"name": "Brigade of the Sky Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10009"
},
{
"item_id": 10010,
"name": "Omen of the Ten Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10010"
},
{
"item_id": 10011,
"name": "Altersphere Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10011"
},
{
"item_id": 10012,
"name": "Steel Rebellion Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10012"
},
{
"item_id": 10013,
"name": "Rebirth of Glory Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10013"
},
{
"item_id": 10014,
"name": "Verdant Conflict Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10014"
},
{
"item_id": 10015,
"name": "Ultimate Colosseum Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10015"
},
{
"item_id": 10016,
"name": "World Uprooted Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10016"
},
{
"item_id": 10017,
"name": "Fortune's Hand Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10017"
},
{
"item_id": 10018,
"name": "Storm Over Rivayle Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10018"
},
{
"item_id": 10019,
"name": "Eternal Awakening Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10019"
},
{
"item_id": 10020,
"name": "Darkness Over Vellsar Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10020"
},
{
"item_id": 10021,
"name": "Renascent Chronicles Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10021"
},
{
"item_id": 10022,
"name": "Dawn of Calamity Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10022"
},
{
"item_id": 10023,
"name": "Omen of Storms Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10023"
},
{
"item_id": 10024,
"name": "Edge of Paradise Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10024"
},
{
"item_id": 10025,
"name": "Roar of the Godwyrm Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10025"
},
{
"item_id": 10026,
"name": "Celestial Dragonblade Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10026"
},
{
"item_id": 10027,
"name": "Eightfold Abyss: Azvaldt Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10027"
},
{
"item_id": 10028,
"name": "Academy of Ages Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10028"
},
{
"item_id": 10029,
"name": "Heroes of Rivenbrandt Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10029"
},
{
"item_id": 10030,
"name": "Order Shift Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10030"
},
{
"item_id": 10031,
"name": "Resurgent Legends Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10031"
},
{
"item_id": 10032,
"name": "Heroes of Shadowverse Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_10032"
},
{
"item_id": 60001,
"name": "4th Birthday Temporary Deck Ticket",
"type": 6,
"thumbnail_path": "ticket_60001"
},
{
"item_id": 60019,
"name": "Eternal Awakening Temporary Deck Ticket",
"type": 6,
"thumbnail_path": "ticket_60019"
},
{
"item_id": 60020,
"name": "Darkness Over Vellsar Temporary Deck Ticket",
"type": 6,
"thumbnail_path": "ticket_60020"
},
{
"item_id": 60021,
"name": "Renascent Chronicles Temporary Deck Ticket",
"type": 6,
"thumbnail_path": "ticket_60021"
},
{
"item_id": 60022,
"name": "Dawn of Calamity Temporary Deck Ticket",
"type": 6,
"thumbnail_path": "ticket_60022"
},
{
"item_id": 60023,
"name": "Omen of Storms Temporary Deck Ticket",
"type": 6,
"thumbnail_path": "ticket_60023"
},
{
"item_id": 60024,
"name": "Edge of Paradise Temporary Deck Ticket",
"type": 6,
"thumbnail_path": "ticket_60024"
},
{
"item_id": 60025,
"name": "Roar of the Godwyrm Temporary Deck Ticket",
"type": 6,
"thumbnail_path": "ticket_60025"
},
{
"item_id": 60026,
"name": "Celestial Dragonblade Temporary Deck Ticket",
"type": 6,
"thumbnail_path": "ticket_60026"
},
{
"item_id": 60027,
"name": "Eightfold Abyss: Azvaldt Temporary Deck Ticket",
"type": 6,
"thumbnail_path": "ticket_60027"
},
{
"item_id": 60028,
"name": "Academy of Ages Temporary Deck Ticket",
"type": 6,
"thumbnail_path": "ticket_60028"
},
{
"item_id": 60029,
"name": "Heroes of Rivenbrandt Temporary Deck Ticket",
"type": 6,
"thumbnail_path": "ticket_60029"
},
{
"item_id": 60030,
"name": "Order Shift Temporary Deck Ticket",
"type": 6,
"thumbnail_path": "ticket_60030"
},
{
"item_id": 60031,
"name": "Resurgent Legends Temporary Deck Ticket",
"type": 6,
"thumbnail_path": "ticket_60031"
},
{
"item_id": 60032,
"name": "Heroes of Shadowverse Temporary Deck Ticket",
"type": 6,
"thumbnail_path": "ticket_60032"
},
{
"item_id": 70001,
"name": "4th Birthday Leader Ticket",
"type": 7,
"thumbnail_path": "ticket_70001"
},
{
"item_id": 70002,
"name": "Champion's Battle Leader Ticket",
"type": 7,
"thumbnail_path": "ticket_70002"
},
{
"item_id": 80001,
"name": "Throwback Rotation Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_80001"
},
{
"item_id": 90001,
"name": "Legendary Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_90001"
},
{
"item_id": 92001,
"name": "4th Birthday Card Pack Ticket",
"type": 2,
"thumbnail_path": "ticket_92001"
}
]

View File

@@ -0,0 +1,52 @@
using Microsoft.EntityFrameworkCore;
using SVSim.Bootstrap.Models.Seed;
using SVSim.Database;
using SVSim.Database.Models;
namespace SVSim.Bootstrap.Importers;
/// <summary>
/// Idempotent upsert of the item catalog from <c>seeds/items.json</c>. Source is the client's
/// <c>item_master.csv</c> + <c>itemtext.json</c> (extracted via
/// <c>data_dumps/extract/extract-items.py</c>). Rows missing from the seed are LEFT INTACT.
/// </summary>
public class ItemImporter
{
public async Task<int> ImportAsync(SVSimDbContext context, string seedDir)
{
string path = Path.Combine(seedDir, "items.json");
var seed = SeedLoader.LoadList<ItemSeed>(path);
if (seed.Count == 0)
{
Console.WriteLine("[ItemImporter] No seed rows; skipping.");
return 0;
}
var existing = await context.Items.ToDictionaryAsync(e => e.Id);
int created = 0, updated = 0;
foreach (var s in seed)
{
if (s.ItemId == 0) continue;
var entry = existing.TryGetValue(s.ItemId, out var ex)
? ex : new ItemEntry { Id = s.ItemId };
entry.Name = s.Name;
entry.Type = s.Type;
entry.ThumbnailPath = s.ThumbnailPath;
if (ex is null)
{
context.Items.Add(entry);
existing[s.ItemId] = entry;
created++;
}
else updated++;
}
await context.SaveChangesAsync();
Console.WriteLine($"[ItemImporter] +{created}/~{updated}");
return created + updated;
}
}

View File

@@ -0,0 +1,11 @@
using System.Text.Json.Serialization;
namespace SVSim.Bootstrap.Models.Seed;
public sealed class ItemSeed
{
[JsonPropertyName("item_id")] public int ItemId { get; set; }
[JsonPropertyName("name")] public string Name { get; set; } = "";
[JsonPropertyName("type")] public int Type { get; set; }
[JsonPropertyName("thumbnail_path")] public string ThumbnailPath { get; set; } = "";
}

View File

@@ -97,6 +97,7 @@ public static class Program
await new PracticeOpponentImporter().ImportAsync(context, opts.SeedDir);
await new PaymentItemImporter().ImportAsync(context, opts.SeedDir);
await new ItemImporter().ImportAsync(context, opts.SeedDir);
var puzzleImporter = new PuzzleImporter();
await puzzleImporter.ImportGroupsAsync(context, opts.SeedDir);
await puzzleImporter.ImportPuzzlesAsync(context, opts.SeedDir);

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1095,6 +1095,13 @@ namespace SVSim.Database.Migrations
.IsRequired()
.HasColumnType("text");
b.Property<string>("ThumbnailPath")
.IsRequired()
.HasColumnType("text");
b.Property<int>("Type")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("Items");

View File

@@ -2,7 +2,21 @@ using SVSim.Database.Common;
namespace SVSim.Database.Models;
/// <summary>
/// Item master row. Mirrors the client's <c>item_master.csv</c> + <c>itemtext.json</c>
/// (under <c>data_dumps/client_master_csv/</c>): <see cref="Type"/> matches the client-side
/// item_type enum (1 = challenge ticket, 2 = card-pack ticket, 3 = premium orb,
/// 4 = colosseum ticket, 5 = orb piece, 6 = skin/event ticket, 7 = other);
/// <see cref="ThumbnailPath"/> is the client-resolved sprite key.
/// </summary>
public class ItemEntry : BaseEntity<int>
{
public string Name { get; set; } = string.Empty;
/// <summary>Client-side item_type enum (1-7). Drives shop categorisation, e.g.
/// <c>user_card_pack_ticket_list</c> in /item_purchase/info filters on Type == 2.</summary>
public int Type { get; set; }
/// <summary>Sprite key, e.g. <c>"ticket_10032"</c>. Empty when unknown.</summary>
public string ThumbnailPath { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,92 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SVSim.Bootstrap.Importers;
using SVSim.Database;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Importers;
public class ItemImporterTests
{
private static string SeedDir => Path.Combine(AppContext.BaseDirectory, "Data", "seeds");
[Test]
public async Task Imports_items_from_seed_file()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
await new ItemImporter().ImportAsync(db, SeedDir);
var items = await db.Items.OrderBy(i => i.Id).ToListAsync();
Assert.That(items.Count, Is.GreaterThan(0), "seed file must contain items");
// Spot-check the card-pack-ticket cluster: Type==2, thumbnail follows ticket_<id> convention.
var pack = items.FirstOrDefault(i => i.Id == 10032);
Assert.That(pack, Is.Not.Null, "card-pack ticket 10032 (latest expansion) should be seeded");
Assert.That(pack!.Type, Is.EqualTo(2));
Assert.That(pack.ThumbnailPath, Is.EqualTo("ticket_10032"));
Assert.That(pack.Name, Is.Not.Empty, "name should resolve via itemtext");
}
[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 ItemImporter().ImportAsync(db, SeedDir);
int before = await db.Items.CountAsync();
await new ItemImporter().ImportAsync(db, SeedDir);
int after = await db.Items.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>();
const int legacyId = 99999;
db.Items.Add(new SVSim.Database.Models.ItemEntry
{
Id = legacyId,
Name = "legacy",
Type = 0,
ThumbnailPath = "",
});
await db.SaveChangesAsync();
await new ItemImporter().ImportAsync(db, SeedDir);
var legacy = await db.Items.FindAsync(legacyId);
Assert.That(legacy, Is.Not.Null, "seed-missing row must be left intact");
Assert.That(legacy!.Name, Is.EqualTo("legacy"));
}
[Test]
public async Task Skips_rows_with_zero_item_id()
{
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, "items.json"),
"[{\"item_id\":0,\"name\":\"junk\",\"type\":1,\"thumbnail_path\":\"\"}]");
await new ItemImporter().ImportAsync(db, tmp);
int count = await db.Items.CountAsync();
Assert.That(count, Is.EqualTo(0), "rows with item_id=0 must not be inserted");
}
finally { Directory.Delete(tmp, true); }
}
}

View File

@@ -208,6 +208,7 @@ internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
await new PracticeOpponentImporter().ImportAsync(ctx, seedDir);
await new PaymentItemImporter().ImportAsync(ctx, seedDir);
await new ItemImporter().ImportAsync(ctx, seedDir);
var puzzleImporter = new PuzzleImporter();
await puzzleImporter.ImportGroupsAsync(ctx, seedDir);
await puzzleImporter.ImportPuzzlesAsync(ctx, seedDir);