Files
SVSimServer/SVSim.Database/Repositories/Viewer/ViewerRepository.cs
gamer147 22c01ed11a fix(viewer): fresh signups start with empty DisplayName
Verified against Wizard.Title/UserNameInput.cs:30 in the 2026-05-23
decompile:

    IsFinished = !string.IsNullOrEmpty(PlayerStaticData.UserName);

Any non-empty seeded value — including the prior " - " placeholder
this method was passing — sets IsFinished=true on the first frame and
silently skips both the input dialog and the /tutorial/update_action #1
+ /account/update_name calls that travel with it. The in-source comment
described the opposite behavior; empty string is what actually triggers
the dialog.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:19:03 -04:00

277 lines
13 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
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 "&lt;unknown&gt;"
/// 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 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();
}
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,
Level = 0,
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;
}
}