Files
SVSimServer/SVSim.Database/Repositories/Viewer/ViewerRepository.cs
gamer147 f233a8c8d6 fix(viewer): fresh signups start at tutorial_state=0, not 100
Previously RegisterAnonymousViewer auto-completed the tutorial, which
prevented the client from ever entering the tutorial flow. SeedViewerAsync
gains a tutorialState parameter (default 100) so existing tests keep
their pre-completed-tutorial assumption.
2026-05-28 11:27:37 -04:00

228 lines
10 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)
{
var viewer = await BuildDefaultViewer(displayName);
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.");
var viewer = await BuildDefaultViewer("Player");
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)
?? throw new InvalidOperationException(
$"Got unique-violation on Udid={udid} insert but subsequent lookup found no row. " +
"This shouldn't happen — likely transaction isolation issue.");
return existing;
}
return viewer;
}
/// <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)
{
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;
viewer.MissionData.TutorialState = 0; // PRE_TUTORIAL_STEP — fresh signups go through the tutorial flow
// 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);
if (defaultEmblem is not null) viewer.Emblems.Add(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;
}
}