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>
345 lines
16 KiB
C#
345 lines
16 KiB
C#
using Microsoft.EntityFrameworkCore;
|
||
using Npgsql;
|
||
using SVSim.Database.Enums;
|
||
using SVSim.Database.Models;
|
||
using SVSim.Database.Models.Config;
|
||
using SVSim.Database.Services;
|
||
|
||
namespace SVSim.Database.Repositories.Viewer;
|
||
|
||
public class ViewerRepository : IViewerRepository
|
||
{
|
||
protected readonly SVSimDbContext _dbContext;
|
||
private readonly IGameConfigService _config;
|
||
|
||
private const int MaxFriends = 20;
|
||
|
||
public ViewerRepository(SVSimDbContext dbContext, IGameConfigService config)
|
||
{
|
||
_dbContext = dbContext;
|
||
_config = config;
|
||
}
|
||
|
||
public async Task<Models.Viewer?> GetViewerBySocialConnection(SocialAccountType accountType, ulong socialId)
|
||
{
|
||
// SocialAccountConnection is [Owned]-by-Viewer — can't be queried as a top-level Set<T>.
|
||
// Look up the Viewer that has a matching owned connection instead.
|
||
return await _dbContext.Set<Models.Viewer>()
|
||
.AsNoTracking()
|
||
.FirstOrDefaultAsync(v => v.SocialAccountConnections.Any(sac =>
|
||
sac.AccountType == accountType && sac.AccountId == socialId));
|
||
}
|
||
|
||
public async Task<Models.Viewer?> GetViewerWithSocials(long id)
|
||
{
|
||
return await _dbContext.Set<Models.Viewer>().AsNoTracking().Include(viewer => viewer.SocialAccountConnections)
|
||
.FirstOrDefaultAsync(viewer => viewer.Id == id);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Loads a viewer with every navigation property needed to render the home-screen
|
||
/// (/load/index). Heavy query — only call from LoadController.Index.
|
||
/// </summary>
|
||
public async Task<Models.Viewer?> GetViewerByShortUdid(long shortUdid)
|
||
{
|
||
// AsSplitQuery: each Include() collection runs as a separate SELECT instead of one giant
|
||
// LEFT JOIN with a cartesian product on the result set. The combined Decks+DeckCard+Cards+
|
||
// many-to-many-cosmetics shape was producing hundreds of thousands of duplicate rows after
|
||
// the import-time default-deck clone landed (24 decks × 40 DeckCards × N cosmetics each),
|
||
// pushing /load/index to ~17 s/request. Split queries take O(rows) total instead.
|
||
return await _dbContext.Set<Models.Viewer>()
|
||
.AsNoTracking()
|
||
.AsSplitQuery()
|
||
.Include(v => v.MissionData)
|
||
.Include(v => v.Info).ThenInclude(i => i.SelectedEmblem)
|
||
.Include(v => v.Info).ThenInclude(i => i.SelectedDegree)
|
||
.Include(v => v.Currency)
|
||
.Include(v => v.Classes).ThenInclude(c => c.Class).ThenInclude(c => c.LeaderSkins)
|
||
.Include(v => v.Classes).ThenInclude(c => c.LeaderSkin)
|
||
.Include(v => v.Decks).ThenInclude(d => d.Class)
|
||
.Include(v => v.Decks).ThenInclude(d => d.Sleeve)
|
||
.Include(v => v.Decks).ThenInclude(d => d.LeaderSkin)
|
||
.Include(v => v.Cards).ThenInclude(c => c.Card)
|
||
.Include(v => v.Items).ThenInclude(i => i.Item)
|
||
.Include(v => v.Sleeves)
|
||
.Include(v => v.Emblems)
|
||
.Include(v => v.Degrees)
|
||
.Include(v => v.LeaderSkins).ThenInclude(ls => ls.Class)
|
||
.Include(v => v.MyPageBackgrounds)
|
||
.FirstOrDefaultAsync(viewer => viewer.ShortUdid == shortUdid);
|
||
}
|
||
|
||
public async Task<Models.Viewer> RegisterViewer(string displayName, SocialAccountType socialType,
|
||
ulong socialAccountIdentifier, ulong? shortUdid = null)
|
||
{
|
||
// RegisterViewer is the import / Steam-social path. Default to the post-tutorial baseline
|
||
// (state 100) so AdminController.ImportViewer materializes prod-replicas at the home screen
|
||
// unless the import request explicitly overrides via request.TutorialState. The anonymous
|
||
// signup path (RegisterAnonymousViewer) uses the parameter default of 1.
|
||
var viewer = await BuildDefaultViewer(displayName, initialTutorialState: 100);
|
||
viewer.SocialAccountConnections.Add(new SocialAccountConnection
|
||
{
|
||
AccountId = socialAccountIdentifier,
|
||
AccountType = socialType
|
||
});
|
||
_dbContext.Set<Models.Viewer>().Add(viewer);
|
||
await _dbContext.SaveChangesAsync();
|
||
return viewer;
|
||
}
|
||
|
||
public async Task<Models.Viewer?> GetViewerByUdid(Guid udid)
|
||
{
|
||
if (udid == Guid.Empty) return null;
|
||
return await _dbContext.Set<Models.Viewer>()
|
||
.AsNoTracking()
|
||
.FirstOrDefaultAsync(v => v.Udid == udid);
|
||
}
|
||
|
||
public async Task<Models.Viewer> RegisterAnonymousViewer(Guid udid)
|
||
{
|
||
if (udid == Guid.Empty)
|
||
throw new InvalidOperationException("Cannot register viewer for empty UDID.");
|
||
|
||
// Empty DisplayName is load-bearing: the client's Wizard.Title/UserNameInput.Start
|
||
// does `IsFinished = !string.IsNullOrEmpty(PlayerStaticData.UserName);` — IsFinished
|
||
// true skips the dialog AND the /tutorial/update_action #1 + /account/update_name
|
||
// calls that accompany it. Any non-empty value (including the " - " placeholder this
|
||
// method used to pass) trips that check and silently bypasses the name-entry sub-step.
|
||
// Empty string flows through /load/index → user_info.name → PlayerStaticData.UserName,
|
||
// and the title screen surfaces the input dialog.
|
||
var viewer = await BuildDefaultViewer("");
|
||
viewer.Udid = udid;
|
||
_dbContext.Set<Models.Viewer>().Add(viewer);
|
||
|
||
// 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 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)
|
||
{
|
||
_dbContext.Set<ViewerPresent>().Add(new ViewerPresent
|
||
{
|
||
Viewer = viewer, // EF wires up ViewerId via the nav after Insert
|
||
PresentId = spec.PresentId,
|
||
Status = PresentStatus.Unclaimed,
|
||
RewardType = spec.RewardType,
|
||
RewardDetailId = spec.RewardDetailId,
|
||
RewardCount = spec.RewardCount,
|
||
ItemType = spec.ItemType,
|
||
Message = spec.Message,
|
||
CreatedAt = createdAt,
|
||
Source = "tutorial",
|
||
});
|
||
}
|
||
|
||
try
|
||
{
|
||
await _dbContext.SaveChangesAsync();
|
||
}
|
||
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
|
||
{
|
||
// Concurrent signup for the same UDID raced us to the unique index. The other request
|
||
// already committed a viewer with this UDID — re-read and return it. Detach the local
|
||
// entity first so EF doesn't keep trying to insert the now-orphaned graph.
|
||
//
|
||
// Cross-engine: Postgres surfaces this as Npgsql.PostgresException SqlState "23505";
|
||
// SQLite (test backend) surfaces it as Microsoft.Data.Sqlite.SqliteException with
|
||
// SqliteErrorCode 19 (SQLITE_CONSTRAINT). Matched by type-name to avoid pulling a
|
||
// Sqlite package dep into SVSim.Database.
|
||
_dbContext.Entry(viewer).State = EntityState.Detached;
|
||
var existing = await GetViewerByUdid(udid);
|
||
if (existing is not null) return existing;
|
||
|
||
// Lookup-by-UDID missed → the violation wasn't on the UDID index. Pull the constraint
|
||
// name out of the inner exception so the caller can see which constraint actually
|
||
// blocked the insert (Steam social uniqueness, owned-collection uniqueness, etc.).
|
||
string constraintName = ExtractConstraintName(ex);
|
||
throw new InvalidOperationException(
|
||
$"Got unique-violation on viewer insert for Udid={udid} but the UDID is not in the table. " +
|
||
$"The violated constraint was '{constraintName}'. " +
|
||
"Original exception preserved as InnerException.",
|
||
ex);
|
||
}
|
||
return viewer;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Extracts the violated constraint name from a wrapped backend exception, when available.
|
||
/// Postgres surfaces this as <c>PostgresException.ConstraintName</c>. Returns "<unknown>"
|
||
/// for other backends or when the name can't be reflected out.
|
||
/// </summary>
|
||
private static string ExtractConstraintName(DbUpdateException ex)
|
||
{
|
||
if (ex.InnerException is Npgsql.PostgresException pgEx && !string.IsNullOrEmpty(pgEx.ConstraintName))
|
||
{
|
||
return pgEx.ConstraintName;
|
||
}
|
||
// SQLite doesn't expose a constraint name in a structured field — fall back to the message.
|
||
if (ex.InnerException is { } inner && inner.GetType().FullName == "Microsoft.Data.Sqlite.SqliteException")
|
||
{
|
||
return inner.Message;
|
||
}
|
||
return "<unknown>";
|
||
}
|
||
|
||
/// <summary>
|
||
/// Returns true if the given <see cref="DbUpdateException"/> wraps a backend-level unique-
|
||
/// constraint violation. Postgres → SqlState "23505"; SQLite → SqliteErrorCode 19.
|
||
/// </summary>
|
||
private static bool IsUniqueViolation(DbUpdateException ex)
|
||
{
|
||
if (ex.InnerException is Npgsql.PostgresException pgEx && pgEx.SqlState == "23505")
|
||
{
|
||
return true;
|
||
}
|
||
// Match SQLite by type name so this assembly doesn't take a dep on Microsoft.Data.Sqlite.
|
||
// Test backend (SQLite in-memory) raises SqliteException with SqliteErrorCode 19 on UNIQUE
|
||
// constraint violations.
|
||
var inner = ex.InnerException;
|
||
if (inner is not null && inner.GetType().FullName == "Microsoft.Data.Sqlite.SqliteException")
|
||
{
|
||
var prop = inner.GetType().GetProperty("SqliteErrorCode");
|
||
if (prop?.GetValue(inner) is int code && code == 19) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
public async Task MergeAnonymousViewerInto(long anonymousViewerId, long targetViewerId)
|
||
{
|
||
if (anonymousViewerId == targetViewerId) return;
|
||
|
||
var anon = await _dbContext.Set<Models.Viewer>()
|
||
.FirstOrDefaultAsync(v => v.Id == anonymousViewerId);
|
||
if (anon is null) return;
|
||
|
||
var target = await _dbContext.Set<Models.Viewer>()
|
||
.FirstOrDefaultAsync(v => v.Id == targetViewerId)
|
||
?? throw new InvalidOperationException(
|
||
$"Cannot merge anonymous viewer {anonymousViewerId}: target viewer {targetViewerId} not found.");
|
||
|
||
// Two saves: free the UDID slot on the anonymous viewer first (drops the unique-index
|
||
// conflict), then reassign to the target and delete the anonymous row in the second
|
||
// save. The partial-failure mode (first save succeeds, second fails) leaves a benign
|
||
// null-UDID viewer that no client can resolve to — never two rows contending for the
|
||
// same UDID, which is the failure we actually need to prevent.
|
||
Guid? freedUdid = anon.Udid;
|
||
anon.Udid = null;
|
||
await _dbContext.SaveChangesAsync();
|
||
|
||
target.Udid = freedUdid;
|
||
_dbContext.Set<Models.Viewer>().Remove(anon);
|
||
await _dbContext.SaveChangesAsync();
|
||
}
|
||
|
||
public async Task LinkSteamToViewer(long viewerId, ulong steamId)
|
||
{
|
||
var viewer = await _dbContext.Set<Models.Viewer>()
|
||
.Include(v => v.SocialAccountConnections)
|
||
.FirstOrDefaultAsync(v => v.Id == viewerId)
|
||
?? throw new InvalidOperationException($"Viewer {viewerId} not found for Steam link.");
|
||
|
||
bool alreadyLinked = viewer.SocialAccountConnections.Any(sac =>
|
||
sac.AccountType == SocialAccountType.Steam && sac.AccountId == steamId);
|
||
if (alreadyLinked) return;
|
||
|
||
viewer.SocialAccountConnections.Add(new SocialAccountConnection
|
||
{
|
||
AccountId = steamId,
|
||
AccountType = SocialAccountType.Steam
|
||
});
|
||
await _dbContext.SaveChangesAsync();
|
||
}
|
||
|
||
public async Task<Models.Viewer?> LoadForMatchContextAsync(long viewerId)
|
||
{
|
||
return await _dbContext.Set<Models.Viewer>()
|
||
.AsNoTracking()
|
||
.Include(v => v.Info.SelectedEmblem)
|
||
.Include(v => v.Info.SelectedDegree)
|
||
.FirstOrDefaultAsync(v => v.Id == viewerId);
|
||
}
|
||
|
||
private async Task<Models.Viewer> BuildDefaultViewer(string displayName, int initialTutorialState = 1)
|
||
{
|
||
Models.Viewer viewer = new Models.Viewer
|
||
{
|
||
DisplayName = displayName
|
||
};
|
||
var player = _config.Get<PlayerConfig>();
|
||
var grants = _config.Get<DefaultGrantsConfig>();
|
||
var loadout = _config.Get<DefaultLoadoutConfig>();
|
||
|
||
viewer.Info.MaxFriends = player.MaxFriends;
|
||
viewer.Info.CountryCode = "KOR";
|
||
viewer.Info.BirthDate = DateTime.UtcNow;
|
||
viewer.Currency.Crystals = grants.Crystals;
|
||
viewer.Currency.Rupees = grants.Rupees;
|
||
viewer.Currency.RedEther = grants.Ether;
|
||
// TUTORIAL_STEP0 (= 1) is the fresh-signup default — see RegisterAnonymousViewer for
|
||
// why step==0 is unsafe. RegisterViewer (admin-import + Steam-social) passes 100 so
|
||
// those callers land at the post-tutorial baseline; import requests can still override
|
||
// via the explicit ImportViewerRequest.TutorialState field.
|
||
viewer.MissionData.TutorialState = initialTutorialState;
|
||
|
||
// Load classes WITH their LeaderSkins — DefaultLeaderSkin iterates the nav collection
|
||
// and would otherwise be null (audit §6 #3 latent NRE — this is the one).
|
||
List<ClassEntry> classes = await _dbContext.Set<ClassEntry>()
|
||
.Include(c => c.LeaderSkins)
|
||
.ToListAsync();
|
||
|
||
viewer.Classes = classes.Select(ce =>
|
||
{
|
||
var skin = ce.DefaultLeaderSkin ?? ce.LeaderSkins.FirstOrDefault();
|
||
return new ViewerClassData
|
||
{
|
||
Class = ce,
|
||
Exp = 0,
|
||
// Client unconditionally indexes `_classCharaExpList[level - 1]` in
|
||
// RankMatchUI.onOpen → CharacterExps.GetClassCharacterExps; level 0 throws IOOR.
|
||
Level = 1,
|
||
LeaderSkin = skin ?? new LeaderSkinEntry { Id = 0, Name = "<missing>", ClassId = ce.Id }
|
||
};
|
||
}).ToList();
|
||
|
||
var defaultSleeveId = loadout.SleeveId;
|
||
var defaultDegreeId = loadout.DegreeId;
|
||
var defaultEmblemId = loadout.EmblemId;
|
||
var defaultBgId = loadout.MyPageBackgroundId;
|
||
var defaultSleeve = await _dbContext.Set<SleeveEntry>().FindAsync(defaultSleeveId);
|
||
var defaultDegree = await _dbContext.Set<DegreeEntry>().FindAsync(defaultDegreeId);
|
||
var defaultEmblem = await _dbContext.Set<EmblemEntry>().FindAsync(defaultEmblemId);
|
||
var defaultBg = await _dbContext.Set<MyPageBackgroundEntry>().FindAsync(defaultBgId);
|
||
if (defaultSleeve is not null) viewer.Sleeves.Add(defaultSleeve);
|
||
if (defaultDegree is not null)
|
||
{
|
||
viewer.Degrees.Add(defaultDegree);
|
||
viewer.Info.SelectedDegree = defaultDegree;
|
||
}
|
||
if (defaultEmblem is not null)
|
||
{
|
||
viewer.Emblems.Add(defaultEmblem);
|
||
viewer.Info.SelectedEmblem = defaultEmblem;
|
||
}
|
||
if (defaultBg is not null) viewer.MyPageBackgrounds.Add(defaultBg);
|
||
|
||
// Grant one of each class's default leader skin. Filter out the synthetic placeholders
|
||
// (Id=0) and dedupe — skins are many-to-many via SleeveEntryViewer-style join.
|
||
var grantedSkins = viewer.Classes
|
||
.Select(vcd => vcd.LeaderSkin)
|
||
.Where(s => s.Id != 0)
|
||
.DistinctBy(s => s.Id)
|
||
.ToList();
|
||
viewer.LeaderSkins.AddRange(grantedSkins);
|
||
|
||
return viewer;
|
||
}
|
||
}
|