Files
gamer147 f754ef1ad3 fix(import): tolerate numeric my_rotation_id; skip empty deck slots
A real /load/index dump emits my_rotation_id as a bare number (0) for
unset MyRotation slots, which 400'd against the string? DTO field
(AllowReadingFromString only covers string->number). FlexibleStringConverter
accepts either form. Also skip empty deck slots (no cards) on import — a
dump carries every slot, mostly empty placeholders the client manages
itself.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 21:03:10 -04:00

297 lines
12 KiB
C#

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Repositories.Viewer;
using SVSim.EmulatedEntrypoint.Extensions;
using SVSim.EmulatedEntrypoint.Infrastructure;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Admin;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Admin;
namespace SVSim.EmulatedEntrypoint.Controllers;
/// <summary>
/// Util endpoints for bootstrapping the dev environment. Anonymous-allowed today — security
/// audit pending (don't expose these to the public internet).
/// </summary>
public class AdminController : SVSimController
{
private readonly IViewerRepository _viewerRepository;
private readonly SVSimDbContext _dbContext;
private readonly ILogger<AdminController> _logger;
public AdminController(IViewerRepository viewerRepository, SVSimDbContext dbContext,
ILogger<AdminController> logger)
{
_viewerRepository = viewerRepository;
_dbContext = dbContext;
_logger = logger;
}
/// <summary>
/// Upsert a viewer from external data (typically captured from the live game via the
/// SVSimLoader dump). Matches existing viewers by SteamId; creates a new one if missing.
/// Only essential fields are imported today — extend as needed.
/// </summary>
[AllowAnonymous]
[HttpPost("import_viewer")]
public async Task<ActionResult<ImportViewerResponse>> ImportViewer(ImportViewerRequest request)
{
if (request.SteamId == 0)
{
return BadRequest("steam_id is required");
}
// SocialAccountConnection is [Owned]-by-Viewer — can't query the owned table directly;
// look up the Viewer with a matching owned connection instead.
var existing = await _dbContext.Viewers
.AsNoTracking()
.FirstOrDefaultAsync(v => v.SocialAccountConnections.Any(sac =>
sac.AccountType == SocialAccountType.Steam && sac.AccountId == request.SteamId));
long viewerId;
bool wasCreated;
if (existing is null)
{
var created = await _viewerRepository.RegisterViewer(
request.DisplayName ?? "Imported Viewer",
SocialAccountType.Steam,
request.SteamId);
viewerId = created.Id;
wasCreated = true;
}
else
{
viewerId = existing.Id;
wasCreated = false;
}
// Reload with all the nav properties we need to mutate. RegisterViewer SaveChanges'd
// already, so we re-fetch with full graph and apply the updates. AsSplitQuery to avoid
// the cartesian-explosion across all the many-to-many cosmetic collections.
var viewer = await _dbContext.Viewers
.AsSplitQuery()
.Include(v => v.Info).ThenInclude(i => i.SelectedEmblem)
.Include(v => v.Info).ThenInclude(i => i.SelectedDegree)
.Include(v => v.Currency)
.Include(v => v.MissionData)
.Include(v => v.Classes).ThenInclude(c => c.Class)
.Include(v => v.Sleeves)
.Include(v => v.Emblems)
.Include(v => v.Degrees)
.Include(v => v.LeaderSkins)
.Include(v => v.MyPageBackgrounds)
.Include(v => v.Cards).ThenInclude(c => c.Card)
.Include(v => v.Items).ThenInclude(i => i.Item)
.Include(v => v.Decks).ThenInclude(d => d.Cards)
.FirstAsync(v => v.Id == viewerId);
if (request.DisplayName is not null) viewer.DisplayName = request.DisplayName;
if (request.CountryCode is not null) viewer.Info.CountryCode = request.CountryCode;
if (request.TutorialState.HasValue) viewer.MissionData.TutorialState = request.TutorialState.Value;
if (request.Currency is not null)
{
if (request.Currency.Crystals.HasValue) viewer.Currency.Crystals = request.Currency.Crystals.Value;
if (request.Currency.Rupees.HasValue) viewer.Currency.Rupees = request.Currency.Rupees.Value;
if (request.Currency.RedEther.HasValue) viewer.Currency.RedEther = request.Currency.RedEther.Value;
}
if (request.SelectedEmblemId.HasValue)
{
var emblem = await _dbContext.Emblems.FindAsync(request.SelectedEmblemId.Value);
if (emblem is not null) viewer.Info.SelectedEmblem = emblem;
}
if (request.SelectedDegreeId.HasValue)
{
var degree = await _dbContext.Degrees.FindAsync(request.SelectedDegreeId.Value);
if (degree is not null) viewer.Info.SelectedDegree = degree;
}
await ReplaceOwned(viewer.Sleeves, request.OwnedSleeveIds, _dbContext.Sleeves);
await ReplaceOwned(viewer.Emblems, request.OwnedEmblemIds, _dbContext.Emblems);
await ReplaceOwned(viewer.Degrees, request.OwnedDegreeIds, _dbContext.Degrees);
await ReplaceOwned(viewer.LeaderSkins, request.OwnedLeaderSkinIds, _dbContext.LeaderSkins);
await ReplaceOwned(viewer.MyPageBackgrounds, request.OwnedMyPageBackgroundIds, _dbContext.MyPageBackgrounds);
if (request.Classes is not null)
{
foreach (var importClass in request.Classes)
{
var existingClass = viewer.Classes.FirstOrDefault(c => c.Class.Id == importClass.ClassId);
if (existingClass is not null)
{
existingClass.Level = importClass.Level;
existingClass.Exp = importClass.Exp;
}
}
}
// Accumulates distinct card_ids referenced by the import (owned list + deck lists)
// that aren't in our card master. Surfaced in the response and logged after save.
var skippedCardIds = new HashSet<long>();
if (request.OwnedCards is not null)
{
var wanted = request.OwnedCards
.GroupBy(c => c.CardId)
.Select(g => g.First())
.ToList();
var ids = wanted.Select(c => c.CardId).ToList();
var cardMaster = await _dbContext.Cards
.Where(c => ids.Contains(c.Id))
.ToDictionaryAsync(c => c.Id);
viewer.Cards.Clear();
foreach (var c in wanted)
{
if (!cardMaster.TryGetValue(c.CardId, out var card))
{
skippedCardIds.Add(c.CardId);
continue;
}
viewer.Cards.Add(new OwnedCardEntry
{
Card = card,
Count = Math.Clamp(c.Count, 1, OwnedCardEntry.MaxCopies),
IsProtected = c.IsProtected,
});
}
}
if (request.Items is not null)
{
var wanted = request.Items
.GroupBy(i => i.ItemId)
.Select(g => g.First())
.ToList();
var ids = wanted.Select(i => i.ItemId).ToList();
var itemMaster = await _dbContext.Items
.Where(i => ids.Contains(i.Id))
.ToDictionaryAsync(i => i.Id);
viewer.Items.Clear();
foreach (var i in wanted)
{
if (!itemMaster.TryGetValue(i.ItemId, out var item)) continue; // unknown master id
viewer.Items.Add(new OwnedItemEntry { Item = item, Count = i.Count, Viewer = viewer });
}
}
if (request.Decks is not null)
{
var allDeckCardIds = request.Decks
.Where(d => d.CardIdArray is not null)
.SelectMany(d => d.CardIdArray!)
.Distinct()
.ToList();
var deckCardMaster = await _dbContext.Cards
.Where(c => allDeckCardIds.Contains(c.Id))
.ToDictionaryAsync(c => c.Id);
var classes = await _dbContext.Classes.Include(c => c.LeaderSkins).ToDictionaryAsync(c => c.Id);
var sleeves = await _dbContext.Sleeves.ToDictionaryAsync(s => (long)s.Id);
var leaderSkins = await _dbContext.LeaderSkins.ToDictionaryAsync(s => s.Id);
var defaultSleeve = await _dbContext.Sleeves.FindAsync((int)DefaultSleeveId);
var latestMyRotationId = (await _dbContext.MyRotationSettings.AsNoTracking()
.Select(s => (int?)s.Id)
.OrderByDescending(id => id)
.FirstOrDefaultAsync())?.ToString();
_dbContext.RemoveRange(viewer.Decks);
viewer.Decks.Clear();
foreach (var d in request.Decks)
{
// A /load/index dump carries every deck slot, most of them empty placeholders
// (no cards). Skip them: the client manages empty slots itself (it's why the old
// default-deck cloning was removed), and importing empty MyRotation slots would
// otherwise persist decks with a bogus rotation id.
if ((d.CardIdArray?.Count ?? 0) == 0) continue;
Format format;
try { format = FormatExtensions.FromApi(d.DeckFormat); }
catch (ArgumentOutOfRangeException) { continue; } // skip unsupported wire format
if (!classes.TryGetValue(d.ClassId, out var classEntry)) continue;
SleeveEntry? sleeve = null;
if (d.SleeveId.HasValue) sleeves.TryGetValue(d.SleeveId.Value, out sleeve);
sleeve ??= defaultSleeve;
LeaderSkinEntry? leaderSkin = null;
if (d.LeaderSkinId.HasValue) leaderSkins.TryGetValue(d.LeaderSkinId.Value, out leaderSkin);
leaderSkin ??= classEntry.DefaultLeaderSkin ?? classEntry.LeaderSkins.FirstOrDefault();
if (sleeve is null || leaderSkin is null) continue;
var cards = (d.CardIdArray ?? new List<long>())
.GroupBy(id => id)
.Where(g =>
{
if (deckCardMaster.ContainsKey(g.Key)) return true;
skippedCardIds.Add(g.Key);
return false;
})
.Select(g => new DeckCard { Card = deckCardMaster[g.Key], Count = g.Count() })
.ToList();
viewer.Decks.Add(new ShadowverseDeckEntry
{
Name = d.DeckName ?? $"Deck {d.DeckNo}",
Number = d.DeckNo,
Format = format,
Class = classEntry,
Sleeve = sleeve,
LeaderSkin = leaderSkin,
RandomLeaderSkin = (d.IsRandomLeaderSkin ?? 0) != 0,
Cards = cards,
MyRotationId = format == Format.MyRotation ? (d.MyRotationId ?? latestMyRotationId) : null,
});
}
}
await _dbContext.SaveChangesAsync();
if (skippedCardIds.Count > 0)
{
_logger.LogWarning(
"ImportViewer (steam_id={SteamId}, viewer_id={ViewerId}): skipped {Count} unknown " +
"card_id(s) not present in the card master. Sample: [{Sample}]",
request.SteamId, viewer.Id, skippedCardIds.Count,
string.Join(", ", skippedCardIds.Take(20)));
}
return new ImportViewerResponse
{
ViewerId = viewer.Id,
ShortUdid = viewer.ShortUdid,
WasCreated = wasCreated,
SkippedCardCount = skippedCardIds.Count,
};
}
/// <summary>
/// Replaces the owned-collection with the master rows matching the supplied ids.
/// Null `ids` is a no-op (preserve existing). Empty list clears the collection.
/// </summary>
private async Task ReplaceOwned<TEntity>(List<TEntity> owned, List<int>? ids, DbSet<TEntity> table)
where TEntity : class
{
if (ids is null) return;
owned.Clear();
if (ids.Count == 0) return;
var rows = await table.Where(e => ids.Contains(EF.Property<int>(e, "Id"))).ToListAsync();
owned.AddRange(rows);
}
/// <summary>
/// Fallback sleeve id used when an imported deck has no resolvable <c>sleeve_id</c>.
/// 3000011 is prod's default deck sleeve.
/// </summary>
private const long DefaultSleeveId = 3000011L;
}