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

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");
});
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 =>
{
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.Models;
using SVSim.Database.Models.Config;
using SVSim.Database.SeedData;
using SVSim.Database.Services;
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
// walks the user to it (which happens AFTER initial battles, per the gift-inbox
// design). The unique (ViewerId, PresentId) index is the backstop against
// double-seeding on a retried signup. Both inserts commit in a single SaveChanges.
// design). The catalogue lives in TutorialPresentEntries (loaded from
// 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;
foreach (var spec in TutorialPresents.All)
foreach (var spec in tutorialPresents)
{
_dbContext.Set<ViewerPresent>().Add(new ViewerPresent
{

View File

@@ -102,6 +102,7 @@ public class SVSimDbContext : DbContext
public DbSet<ViewerStoryBranchUnlock> ViewerStoryBranchUnlocks => Set<ViewerStoryBranchUnlock>();
public DbSet<ViewerPresent> ViewerPresents => Set<ViewerPresent>();
public DbSet<TutorialPresentEntry> TutorialPresentEntries => Set<TutorialPresentEntry>();
public DbSet<ArenaTwoPickReward> ArenaTwoPickRewards { 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();
});
modelBuilder.Entity<TutorialPresentEntry>(b =>
{
b.HasKey(p => p.PresentId);
b.Property(p => p.PresentId).HasMaxLength(64);
});
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
};
}