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:
7
SVSim.Bootstrap/Data/seeds/tutorial-presents.json
Normal file
7
SVSim.Bootstrap/Data/seeds/tutorial-presents.json
Normal 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" }
|
||||||
|
]
|
||||||
48
SVSim.Bootstrap/Importers/TutorialPresentsImporter.cs
Normal file
48
SVSim.Bootstrap/Importers/TutorialPresentsImporter.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
SVSim.Bootstrap/Models/Seed/TutorialPresentSeed.cs
Normal file
13
SVSim.Bootstrap/Models/Seed/TutorialPresentSeed.cs
Normal 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; } = "";
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
4244
SVSim.Database/Migrations/20260609132350_AddTutorialPresentEntries.Designer.cs
generated
Normal file
4244
SVSim.Database/Migrations/20260609132350_AddTutorialPresentEntries.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
|||||||
18
SVSim.Database/Models/TutorialPresentEntry.cs
Normal file
18
SVSim.Database/Models/TutorialPresentEntry.cs
Normal 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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user