refactor(tutorial-presents): promote static catalogue to seed-driven TutorialPresentEntries table

The five tutorial gifts every fresh viewer is given at signup used to live as a
static C# array in SVSim.Database/SeedData/TutorialPresents.cs — outside the
seed-JSON pipeline used by all 40+ other globals tables. Editing a gift required
a code change + rebuild instead of a JSON edit + bootstrap re-run.

Now authored in SVSim.Bootstrap/Data/seeds/tutorial-presents.json and loaded into
a new TutorialPresentEntries table via TutorialPresentsImporter (clear-and-rewrite,
mirroring HomeDialogs). ViewerRepository.RegisterAnonymousViewer reads the table
at signup and projects each row into a ViewerPresent with Source="tutorial".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-09 09:53:10 -04:00
parent 7118b92522
commit 998402ebc3
12 changed files with 4434 additions and 39 deletions

View File

@@ -0,0 +1,7 @@
[
{ "present_id": "71478626", "reward_type": 1, "reward_detail_id": 0, "reward_count": 400, "item_type": null, "message": "For completing the tutorial" },
{ "present_id": "71478627", "reward_type": 9, "reward_detail_id": 0, "reward_count": 100, "item_type": null, "message": "For completing the tutorial" },
{ "present_id": "71478628", "reward_type": 4, "reward_detail_id": 1, "reward_count": 3, "item_type": 1, "message": "For completing the tutorial" },
{ "present_id": "71478629", "reward_type": 4, "reward_detail_id": 80001, "reward_count": 40, "item_type": 2, "message": "For completing the tutorial" },
{ "present_id": "71478630", "reward_type": 4, "reward_detail_id": 90001, "reward_count": 1, "item_type": 2, "message": "For completing the tutorial" }
]

View File

@@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore;
using SVSim.Bootstrap.Models.Seed;
using SVSim.Database;
using SVSim.Database.Models;
namespace SVSim.Bootstrap.Importers;
/// <summary>
/// Loads the tutorial-gift catalogue (<c>tutorial-presents.json</c>) into the
/// <c>TutorialPresentEntries</c> table. Clear-and-rewrite — the seed file is authoritative;
/// hand-edits to the table are not preserved.
///
/// Read side: <c>ViewerRepository.RegisterAnonymousViewer</c> reads this table and projects
/// each row into a <c>ViewerPresent</c> with Source="tutorial" at signup time.
/// </summary>
public class TutorialPresentsImporter
{
public async Task<int> ImportAsync(SVSimDbContext context, string seedDir)
{
var seed = SeedLoader.LoadList<TutorialPresentSeed>(
Path.Combine(seedDir, "tutorial-presents.json"));
if (seed.Count == 0)
{
Console.WriteLine("[TutorialPresentsImporter] No tutorial-present seed rows; skipping.");
return 0;
}
var existing = await context.TutorialPresentEntries.ToListAsync();
context.TutorialPresentEntries.RemoveRange(existing);
foreach (var s in seed)
{
context.TutorialPresentEntries.Add(new TutorialPresentEntry
{
PresentId = s.PresentId,
RewardType = s.RewardType,
RewardDetailId = s.RewardDetailId,
RewardCount = s.RewardCount,
ItemType = s.ItemType,
Message = s.Message,
});
}
await context.SaveChangesAsync();
Console.WriteLine($"[TutorialPresentsImporter] TutorialPresentEntries: -{existing.Count}/+{seed.Count}");
return seed.Count;
}
}

View File

@@ -0,0 +1,13 @@
using System.Text.Json.Serialization;
namespace SVSim.Bootstrap.Models.Seed;
public sealed class TutorialPresentSeed
{
[JsonPropertyName("present_id")] public string PresentId { get; set; } = "";
[JsonPropertyName("reward_type")] public int RewardType { get; set; }
[JsonPropertyName("reward_detail_id")] public long RewardDetailId { get; set; }
[JsonPropertyName("reward_count")] public long RewardCount { get; set; }
[JsonPropertyName("item_type")] public int? ItemType { get; set; }
[JsonPropertyName("message")] public string Message { get; set; } = "";
}

View File

@@ -117,6 +117,8 @@ public static class Program
await mypage.ImportSpecialDeckFormatsAsync(context, opts.SeedDir); await mypage.ImportSpecialDeckFormatsAsync(context, opts.SeedDir);
await mypage.ImportHomeDialogsAsync(context, opts.SeedDir); await mypage.ImportHomeDialogsAsync(context, opts.SeedDir);
await new TutorialPresentsImporter().ImportAsync(context, opts.SeedDir);
await new DefaultDeckImporter().ImportAsync(context, opts.SeedDir); await new DefaultDeckImporter().ImportAsync(context, opts.SeedDir);
await new PackImporter().ImportAsync(context, opts.SeedDir); await new PackImporter().ImportAsync(context, opts.SeedDir);
await new PackDrawTableImporter().ImportAsync(context, opts.SeedDir); await new PackDrawTableImporter().ImportAsync(context, opts.SeedDir);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SVSim.Database.Migrations
{
/// <inheritdoc />
public partial class AddTutorialPresentEntries : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "TutorialPresentEntries",
columns: table => new
{
PresentId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
RewardType = table.Column<int>(type: "integer", nullable: false),
RewardDetailId = table.Column<long>(type: "bigint", nullable: false),
RewardCount = table.Column<long>(type: "bigint", nullable: false),
ItemType = table.Column<int>(type: "integer", nullable: true),
Message = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TutorialPresentEntries", x => x.PresentId);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "TutorialPresentEntries");
}
}
}

View File

@@ -2530,6 +2530,33 @@ namespace SVSim.Database.Migrations
b.ToTable("StoryDecks"); b.ToTable("StoryDecks");
}); });
modelBuilder.Entity("SVSim.Database.Models.TutorialPresentEntry", b =>
{
b.Property<string>("PresentId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int?>("ItemType")
.HasColumnType("integer");
b.Property<string>("Message")
.IsRequired()
.HasColumnType("text");
b.Property<long>("RewardCount")
.HasColumnType("bigint");
b.Property<long>("RewardDetailId")
.HasColumnType("bigint");
b.Property<int>("RewardType")
.HasColumnType("integer");
b.HasKey("PresentId");
b.ToTable("TutorialPresentEntries");
});
modelBuilder.Entity("SVSim.Database.Models.UnlimitedRestrictionEntry", b => modelBuilder.Entity("SVSim.Database.Models.UnlimitedRestrictionEntry", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")

View File

@@ -0,0 +1,18 @@
namespace SVSim.Database.Models;
/// <summary>
/// One row in the tutorial-gift catalogue every fresh viewer is given at signup. Authored in
/// <c>SVSim.Bootstrap/Data/seeds/tutorial-presents.json</c>; <see cref="PresentId"/> is the
/// wire-stable identifier and serves as the primary key. <c>ViewerRepository.RegisterAnonymousViewer</c>
/// reads this table and projects each row into a <see cref="ViewerPresent"/> with Source="tutorial".
/// </summary>
public class TutorialPresentEntry
{
public string PresentId { get; set; } = string.Empty;
public int RewardType { get; set; }
public long RewardDetailId { get; set; }
public long RewardCount { get; set; }
public int? ItemType { get; set; }
public string Message { get; set; } = string.Empty;
}

View File

@@ -3,7 +3,6 @@ using Npgsql;
using SVSim.Database.Enums; using SVSim.Database.Enums;
using SVSim.Database.Models; using SVSim.Database.Models;
using SVSim.Database.Models.Config; using SVSim.Database.Models.Config;
using SVSim.Database.SeedData;
using SVSim.Database.Services; using SVSim.Database.Services;
namespace SVSim.Database.Repositories.Viewer; namespace SVSim.Database.Repositories.Viewer;
@@ -114,10 +113,17 @@ public class ViewerRepository : IViewerRepository
// Eager-seed the tutorial gifts so the inbox is populated by the time the tutorial // Eager-seed the tutorial gifts so the inbox is populated by the time the tutorial
// walks the user to it (which happens AFTER initial battles, per the gift-inbox // walks the user to it (which happens AFTER initial battles, per the gift-inbox
// design). The unique (ViewerId, PresentId) index is the backstop against // design). The catalogue lives in TutorialPresentEntries (loaded from
// double-seeding on a retried signup. Both inserts commit in a single SaveChanges. // SVSim.Bootstrap/Data/seeds/tutorial-presents.json); if the catalogue is empty
// (bootstrap not run) signup still succeeds with an empty inbox. The unique
// (ViewerId, PresentId) index is the backstop against double-seeding on a retried
// signup. Both inserts commit in a single SaveChanges.
var tutorialPresents = await _dbContext.Set<TutorialPresentEntry>()
.AsNoTracking()
.OrderBy(p => p.PresentId)
.ToListAsync();
var createdAt = DateTime.UtcNow; var createdAt = DateTime.UtcNow;
foreach (var spec in TutorialPresents.All) foreach (var spec in tutorialPresents)
{ {
_dbContext.Set<ViewerPresent>().Add(new ViewerPresent _dbContext.Set<ViewerPresent>().Add(new ViewerPresent
{ {

View File

@@ -102,6 +102,7 @@ public class SVSimDbContext : DbContext
public DbSet<ViewerStoryBranchUnlock> ViewerStoryBranchUnlocks => Set<ViewerStoryBranchUnlock>(); public DbSet<ViewerStoryBranchUnlock> ViewerStoryBranchUnlocks => Set<ViewerStoryBranchUnlock>();
public DbSet<ViewerPresent> ViewerPresents => Set<ViewerPresent>(); public DbSet<ViewerPresent> ViewerPresents => Set<ViewerPresent>();
public DbSet<TutorialPresentEntry> TutorialPresentEntries => Set<TutorialPresentEntry>();
public DbSet<ArenaTwoPickReward> ArenaTwoPickRewards { get; set; } = null!; public DbSet<ArenaTwoPickReward> ArenaTwoPickRewards { get; set; } = null!;
public DbSet<ViewerArenaTwoPickRun> ViewerArenaTwoPickRuns { get; set; } = null!; public DbSet<ViewerArenaTwoPickRun> ViewerArenaTwoPickRuns { get; set; } = null!;
@@ -391,6 +392,12 @@ public class SVSimDbContext : DbContext
b.HasIndex(p => new { p.ViewerId, p.PresentId }).IsUnique(); b.HasIndex(p => new { p.ViewerId, p.PresentId }).IsUnique();
}); });
modelBuilder.Entity<TutorialPresentEntry>(b =>
{
b.HasKey(p => p.PresentId);
b.Property(p => p.PresentId).HasMaxLength(64);
});
base.OnModelCreating(modelBuilder); base.OnModelCreating(modelBuilder);
} }

View File

@@ -1,30 +0,0 @@
namespace SVSim.Database.SeedData;
/// <summary>
/// The five tutorial gifts every fresh viewer is given at signup. Mirrors the prod-shaped
/// gift inbox — these rows are indistinguishable from server-issued gifts at read time
/// except for the Source tag.
///
/// Moved here from the old static <c>GiftController.TutorialGifts</c> PresentDto array;
/// now insert-only data, not DTOs, so signup writes go straight into ViewerPresent rows
/// without round-tripping through wire types.
/// </summary>
public static class TutorialPresents
{
public record Spec(
string PresentId,
int RewardType,
long RewardDetailId,
long RewardCount,
int? ItemType,
string Message);
public static readonly IReadOnlyList<Spec> All = new[]
{
new Spec("71478626", 1, 0, 400, null, "For completing the tutorial"), // Crystal
new Spec("71478627", 9, 0, 100, null, "For completing the tutorial"), // Rupy
new Spec("71478628", 4, 1, 3, 1, "For completing the tutorial"), // Item type=1, x3 of item 1
new Spec("71478629", 4, 80001, 40, 2, "For completing the tutorial"), // Pack ticket 80001 x40
new Spec("71478630", 4, 90001, 1, 2, "For completing the tutorial"), // Pack ticket 90001 x1
};
}

View File

@@ -278,6 +278,8 @@ internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
await mypage.ImportSpecialDeckFormatsAsync(ctx, seedDir); await mypage.ImportSpecialDeckFormatsAsync(ctx, seedDir);
await mypage.ImportHomeDialogsAsync(ctx, seedDir); await mypage.ImportHomeDialogsAsync(ctx, seedDir);
await new TutorialPresentsImporter().ImportAsync(ctx, seedDir);
await new DefaultDeckImporter().ImportAsync(ctx, seedDir); await new DefaultDeckImporter().ImportAsync(ctx, seedDir);
await new PackImporter().ImportAsync(ctx, seedDir); await new PackImporter().ImportAsync(ctx, seedDir);
// PackDrawTableImporter is NOT called here — production draw tables reference real // PackDrawTableImporter is NOT called here — production draw tables reference real
@@ -563,17 +565,31 @@ internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
} }
/// <summary> /// <summary>
/// Seed the five tutorial ViewerPresent rows for a test viewer. RegisterViewer (admin/social /// Seed the tutorial ViewerPresent rows for a test viewer by projecting from the
/// path) does NOT auto-seed; only the production /tool/signup -> RegisterAnonymousViewer flow /// TutorialPresentEntries catalogue. RegisterViewer (admin/social path) does NOT auto-seed;
/// does. Tests opt in by calling this helper after SeedViewerAsync when they want a tutorial- /// only the production /tool/signup -> RegisterAnonymousViewer flow does. Tests opt in by
/// shaped inbox state. /// calling this helper after SeedViewerAsync when they want a tutorial-shaped inbox state.
/// If the catalogue is empty (most tests skip SeedGlobalsAsync), this method imports
/// tutorial-presents.json on demand so the helper works regardless of test setup ordering.
/// </summary> /// </summary>
public async Task SeedTutorialPresentsAsync(long viewerId) public async Task SeedTutorialPresentsAsync(long viewerId)
{ {
using var scope = Services.CreateScope(); using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>(); var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
if (!await db.TutorialPresentEntries.AnyAsync())
{
string seedDir = Path.Combine(AppContext.BaseDirectory, "Data", "seeds");
await new TutorialPresentsImporter().ImportAsync(db, seedDir);
}
var catalogue = await db.TutorialPresentEntries
.AsNoTracking()
.OrderBy(p => p.PresentId)
.ToListAsync();
var createdAt = DateTime.UtcNow; var createdAt = DateTime.UtcNow;
foreach (var spec in SVSim.Database.SeedData.TutorialPresents.All) foreach (var spec in catalogue)
{ {
db.ViewerPresents.Add(new ViewerPresent db.ViewerPresents.Add(new ViewerPresent
{ {