Practice/deck editing mostly there
This commit is contained in:
@@ -70,7 +70,7 @@ public class CardImporter
|
||||
Id = setId,
|
||||
Name = $"Card Set {setId}",
|
||||
IsInRotation = true,
|
||||
IsBasic = false
|
||||
IsBasic = setId == 10000
|
||||
};
|
||||
context.CardSets.Add(set);
|
||||
existingSets[setId] = set;
|
||||
|
||||
8
SVSim.EmulatedEntrypoint/Configuration/DeckOptions.cs
Normal file
8
SVSim.EmulatedEntrypoint/Configuration/DeckOptions.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace SVSim.EmulatedEntrypoint.Configuration;
|
||||
|
||||
public class DeckOptions
|
||||
{
|
||||
public const string SectionName = "Deck";
|
||||
|
||||
public int MaxDeckSlots { get; set; } = 36;
|
||||
}
|
||||
31
SVSim.EmulatedEntrypoint/Controllers/ConfigController.cs
Normal file
31
SVSim.EmulatedEntrypoint/Controllers/ConfigController.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Common;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// /config/* — viewer-scoped preference toggles. Fired by the client as fire-and-forget
|
||||
/// after the deck-builder save flow (and from settings screens). Currently acknowledge-only:
|
||||
/// UserConfig is stubbed in load/index + mypage/index until a viewer-config persistence
|
||||
/// layer lands. Mirrors the set_deck_redis precedent (no-op accept).
|
||||
/// </summary>
|
||||
public class ConfigController : SVSimController
|
||||
{
|
||||
[HttpPost("update_foil_preferred")]
|
||||
public ActionResult<EmptyResponse> UpdateFoilPreferred(ConfigUpdateFoilPreferredRequest request)
|
||||
{
|
||||
if (!TryGetViewerId(out long _)) return Unauthorized();
|
||||
// TODO(viewer-config-persist): write request.IsFoilPreferred onto the viewer's config row
|
||||
// and read it back in load/index + mypage/index instead of `new UserConfig()`.
|
||||
return new EmptyResponse();
|
||||
}
|
||||
|
||||
[HttpPost("update_prize_preferred")]
|
||||
public ActionResult<EmptyResponse> UpdatePrizePreferred(ConfigUpdatePrizePreferredRequest request)
|
||||
{
|
||||
if (!TryGetViewerId(out long _)) return Unauthorized();
|
||||
// TODO(viewer-config-persist): see UpdateFoilPreferred.
|
||||
return new EmptyResponse();
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Repositories.Deck;
|
||||
using SVSim.Database.Repositories.Globals;
|
||||
using SVSim.EmulatedEntrypoint.Configuration;
|
||||
using SVSim.EmulatedEntrypoint.Constants;
|
||||
using SVSim.EmulatedEntrypoint.Extensions;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
@@ -20,6 +22,7 @@ public class DeckController : SVSimController
|
||||
private readonly IDeckRepository _deckRepository;
|
||||
private readonly IGlobalsRepository _globalsRepository;
|
||||
private readonly SVSimDbContext _dbContext;
|
||||
private readonly DeckOptions _deckOptions;
|
||||
|
||||
private static readonly System.Text.Json.JsonSerializerOptions JsonbReadOptions = new()
|
||||
{
|
||||
@@ -27,11 +30,32 @@ public class DeckController : SVSimController
|
||||
NumberHandling = System.Text.Json.Serialization.JsonNumberHandling.AllowReadingFromString,
|
||||
};
|
||||
|
||||
public DeckController(IDeckRepository deckRepository, IGlobalsRepository globalsRepository, SVSimDbContext dbContext)
|
||||
public DeckController(IDeckRepository deckRepository, IGlobalsRepository globalsRepository, SVSimDbContext dbContext, IOptions<DeckOptions> deckOptions)
|
||||
{
|
||||
_deckRepository = deckRepository;
|
||||
_globalsRepository = globalsRepository;
|
||||
_dbContext = dbContext;
|
||||
_deckOptions = deckOptions.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pads a viewer's real deck list with empty-slot placeholders up to <see cref="DeckOptions.MaxDeckSlots"/>.
|
||||
/// Required because the client's <c>DeckUI.DeckViewData.CreateDeckViewList</c> only renders
|
||||
/// a "New Deck" tile when the response contains an entry whose <c>card_id_array</c> is empty —
|
||||
/// without padding, the player cannot create additional decks once any exist.
|
||||
/// </summary>
|
||||
private List<UserDeck> PadEmptySlots(List<UserDeck> realDecks)
|
||||
{
|
||||
var taken = realDecks.Select(d => d.DeckNumber).ToHashSet();
|
||||
var result = new List<UserDeck>(realDecks);
|
||||
for (int slot = 1; slot <= _deckOptions.MaxDeckSlots; slot++)
|
||||
{
|
||||
if (!taken.Contains(slot))
|
||||
{
|
||||
result.Add(UserDeck.CreateEmptySlot(slot));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Request deck_format fields arrive as wire ints (MessagePack-CSharp doesn't honor STJ
|
||||
@@ -106,9 +130,9 @@ public class DeckController : SVSimController
|
||||
// for our profile; we mirror that omission and leave the nullable DTO fields unset.
|
||||
var formats = new[] { Format.Rotation, Format.Unlimited, Format.MyRotation };
|
||||
var byFormat = await _deckRepository.GetDecksByFormats(viewerId, formats);
|
||||
response.UserDeckRotation = byFormat[Format.Rotation].Select(d => new UserDeck(d)).ToList();
|
||||
response.UserDeckUnlimited = byFormat[Format.Unlimited].Select(d => new UserDeck(d)).ToList();
|
||||
response.UserDeckMyRotation = byFormat[Format.MyRotation].Select(d => new UserDeck(d)).ToList();
|
||||
response.UserDeckRotation = PadEmptySlots(byFormat[Format.Rotation].Select(d => new UserDeck(d)).ToList());
|
||||
response.UserDeckUnlimited = PadEmptySlots(byFormat[Format.Unlimited].Select(d => new UserDeck(d)).ToList());
|
||||
response.UserDeckMyRotation = PadEmptySlots(byFormat[Format.MyRotation].Select(d => new UserDeck(d)).ToList());
|
||||
// trial_deck_list is prod-emitted on /deck/info (All format) but omitted on /deck/my_list
|
||||
// (specific format). Empty array in the 2026-05-23 prod capture.
|
||||
response.TrialDeckList = new();
|
||||
@@ -116,7 +140,7 @@ public class DeckController : SVSimController
|
||||
else
|
||||
{
|
||||
var decks = await _deckRepository.GetDecks(viewerId, requestFormat);
|
||||
response.UserDeckList = decks.Select(d => new UserDeck(d)).ToList();
|
||||
response.UserDeckList = PadEmptySlots(decks.Select(d => new UserDeck(d)).ToList());
|
||||
}
|
||||
|
||||
return response;
|
||||
@@ -163,7 +187,7 @@ public class DeckController : SVSimController
|
||||
var decks = await _deckRepository.GetDecks(viewerId, format);
|
||||
return new DeckUpdateResponse
|
||||
{
|
||||
UserDeckList = decks.Select(d => new UserDeck(d)).ToList()
|
||||
UserDeckList = PadEmptySlots(decks.Select(d => new UserDeck(d)).ToList())
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using MessagePack;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
|
||||
[MessagePackObject]
|
||||
public class ConfigUpdateFoilPreferredRequest : BaseRequest
|
||||
{
|
||||
[JsonPropertyName("is_foil_preferred")]
|
||||
[Key("is_foil_preferred")]
|
||||
public int IsFoilPreferred { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using MessagePack;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
|
||||
[MessagePackObject]
|
||||
public class ConfigUpdatePrizePreferredRequest : BaseRequest
|
||||
{
|
||||
[JsonPropertyName("is_prize_preferred")]
|
||||
[Key("is_prize_preferred")]
|
||||
public int IsPrizePreferred { get; set; }
|
||||
}
|
||||
@@ -54,7 +54,32 @@ public class UserDeck
|
||||
public int Order { get; set; }
|
||||
[JsonPropertyName("create_deck_time")]
|
||||
[Key("create_deck_time")]
|
||||
public DateTime DeckCreateTime { get; set; }
|
||||
public DateTime? DeckCreateTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Empty placeholder matching the wire shape prod uses to pad deck-list responses up to the
|
||||
/// per-format cap. The client's <c>DeckUI.DeckViewData.CreateDeckViewList</c> converts the
|
||||
/// first entry whose <c>card_id_array</c> is empty into the "New Deck" tile, so at least one
|
||||
/// of these must appear in any list the player can edit.
|
||||
/// </summary>
|
||||
public static UserDeck CreateEmptySlot(int deckNo) => new()
|
||||
{
|
||||
DeckNumber = deckNo,
|
||||
ClassId = 1,
|
||||
SleeveId = 3000011,
|
||||
LeaderSkinId = 0,
|
||||
Name = string.Empty,
|
||||
Cards = new(),
|
||||
IsCompleteDeck = 0,
|
||||
RestrictedCardExists = false,
|
||||
IsAvailable = 1,
|
||||
MaintenanceCards = new(),
|
||||
IncludesNonCollectibleCards = false,
|
||||
IsRandomLeaderSkin = 0,
|
||||
LeaderSkinIds = new() { 0 },
|
||||
Order = 0,
|
||||
DeckCreateTime = null,
|
||||
};
|
||||
|
||||
public UserDeck(ShadowverseDeckEntry deck)
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@ using SVSim.Database.Repositories.Collectibles;
|
||||
using SVSim.Database.Repositories.Deck;
|
||||
using SVSim.Database.Repositories.Globals;
|
||||
using SVSim.Database.Repositories.Viewer;
|
||||
using SVSim.EmulatedEntrypoint.Configuration;
|
||||
using SVSim.EmulatedEntrypoint.Extensions;
|
||||
using SVSim.EmulatedEntrypoint.Middlewares;
|
||||
using SVSim.EmulatedEntrypoint.Security.SteamSessionAuthentication;
|
||||
@@ -47,6 +48,8 @@ public class Program
|
||||
|
||||
});
|
||||
|
||||
builder.Services.Configure<DeckOptions>(builder.Configuration.GetSection(DeckOptions.SectionName));
|
||||
|
||||
#region Database Services
|
||||
|
||||
builder.Services.AddDbContext<SVSimDbContext>(opt =>
|
||||
@@ -64,6 +67,7 @@ public class Program
|
||||
builder.Services.AddTransient<ShadowverseTranslationMiddleware>();
|
||||
builder.Services.AddTransient<SessionidMappingMiddleware>();
|
||||
builder.Services.AddSingleton<ShadowverseSessionService>();
|
||||
builder.Services.AddSingleton<ISteamServer, FacepunchSteamServer>();
|
||||
builder.Services.AddSingleton<SteamSessionService>();
|
||||
builder.Services.AddAuthentication()
|
||||
.AddScheme<SteamAuthenticationHandlerOptions, SteamSessionAuthenticationHandler>(
|
||||
|
||||
41
SVSim.EmulatedEntrypoint/Services/FacepunchSteamServer.cs
Normal file
41
SVSim.EmulatedEntrypoint/Services/FacepunchSteamServer.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using Steamworks;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Production <see cref="ISteamServer"/> backed by Facepunch.Steamworks 2.3.3.
|
||||
/// Initialization is process-global; registering this as a singleton is required.
|
||||
/// </summary>
|
||||
public sealed class FacepunchSteamServer : ISteamServer
|
||||
{
|
||||
private readonly object _initLock = new();
|
||||
private bool _initialized;
|
||||
|
||||
public void Initialize(int appId)
|
||||
{
|
||||
if (_initialized) return;
|
||||
lock (_initLock)
|
||||
{
|
||||
if (_initialized) return;
|
||||
SteamServer.Init(appId, new SteamServerInit
|
||||
{
|
||||
GamePort = default,
|
||||
QueryPort = default,
|
||||
});
|
||||
_initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
public bool BeginAuthSession(byte[] ticket, ulong steamId) =>
|
||||
SteamServer.BeginAuthSession(ticket, new SteamId { Value = steamId });
|
||||
|
||||
public void EndSession(ulong steamId) =>
|
||||
SteamServer.EndSession(new SteamId { Value = steamId });
|
||||
|
||||
public void Shutdown()
|
||||
{
|
||||
if (!_initialized) return;
|
||||
SteamServer.Shutdown();
|
||||
_initialized = false;
|
||||
}
|
||||
}
|
||||
34
SVSim.EmulatedEntrypoint/Services/ISteamServer.cs
Normal file
34
SVSim.EmulatedEntrypoint/Services/ISteamServer.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
namespace SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Thin wrapper around the static <c>Steamworks.SteamServer</c> API. Exists purely so
|
||||
/// <see cref="SteamSessionService"/> can be unit-tested — Facepunch.Steamworks is a static
|
||||
/// class with process-global state that can't be mocked or run twice in the same test host.
|
||||
///
|
||||
/// Only the operations <see cref="SteamSessionService"/> actually invokes are exposed. Add
|
||||
/// methods here as needed rather than expanding the surface speculatively.
|
||||
/// </summary>
|
||||
public interface ISteamServer
|
||||
{
|
||||
/// <summary>Initialize the underlying Steam game server with the given app id. Idempotent.</summary>
|
||||
void Initialize(int appId);
|
||||
|
||||
/// <summary>
|
||||
/// Open an auth session for the given steam id with the given ticket bytes. Returns true
|
||||
/// when Steam accepts the ticket. Returns false on any rejection — most commonly the
|
||||
/// "duplicate request" case (the steam id already has an open session on this server),
|
||||
/// which is the failure mode <see cref="SteamSessionService"/> resolves by calling
|
||||
/// <see cref="EndSession"/> first.
|
||||
/// </summary>
|
||||
bool BeginAuthSession(byte[] ticket, ulong steamId);
|
||||
|
||||
/// <summary>
|
||||
/// Close any active auth session for this steam id. Safe to call when no session exists
|
||||
/// (Steam SDK no-ops). Must be called before a second <see cref="BeginAuthSession"/> for
|
||||
/// the same steam id or Steam will reject the new call as a duplicate request.
|
||||
/// </summary>
|
||||
void EndSession(ulong steamId);
|
||||
|
||||
/// <summary>Tear down the game server. Implicitly ends every open auth session.</summary>
|
||||
void Shutdown();
|
||||
}
|
||||
@@ -1,74 +1,138 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Steamworks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Validates Steam session tickets against Steamworks for the auth handler. Owns the
|
||||
/// lifecycle of per-user Steam auth sessions: when a client reconnects with a fresh ticket
|
||||
/// (typical on every client restart — Steam tickets are per-process), we end the prior
|
||||
/// session before opening a new one. Without that, Steam rejects the second
|
||||
/// <c>BeginAuthSession</c> as a duplicate request and the auth handler returns 401.
|
||||
///
|
||||
/// See <c>docs/audits/game-start-steam-ticket-401-on-client-restart-2026-05-23.md</c> for
|
||||
/// the original symptom + the choice of end-then-begin over reactively retrying on
|
||||
/// DuplicateRequest (Facepunch 2.3.3 collapses the BeginAuthResult enum to bool, so we
|
||||
/// can't see the duplicate-request signal directly).
|
||||
/// </summary>
|
||||
public class SteamSessionService : IDisposable
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ulong> _validatedSessionTickets = new();
|
||||
private readonly object _initLock = new();
|
||||
private bool _steamInitialized;
|
||||
|
||||
private const int ShadowVerseAppId = 453480;
|
||||
|
||||
/// <summary>
|
||||
/// Validates if a given session ticket is valid, and matches up with the given steamid.
|
||||
/// </summary>
|
||||
/// <param name="ticket">the ticket, represented as a hexadecimal string</param>
|
||||
/// <param name="steamId">the steamid that should be associated with the ticket</param>
|
||||
/// <returns>whether the ticket is valid for the given steamid</returns>
|
||||
private readonly ISteamServer _steam;
|
||||
private readonly ILogger<SteamSessionService> _logger;
|
||||
|
||||
/// <summary>Ticket-bytes-to-steamid for cache hits on identical re-sends (e.g. retries within one client session).</summary>
|
||||
private readonly ConcurrentDictionary<string, ulong> _validatedSessionTickets = new();
|
||||
|
||||
/// <summary>steamId → currently-open ticket. Single entry per user; replaced when a new ticket supersedes it.</summary>
|
||||
private readonly ConcurrentDictionary<ulong, string> _activeSessionBySteamId = new();
|
||||
|
||||
/// <summary>Per-steamId mutex so the check-end-begin sequence is atomic for that user without serializing all auth.</summary>
|
||||
private readonly ConcurrentDictionary<ulong, SemaphoreSlim> _steamIdLocks = new();
|
||||
|
||||
public SteamSessionService(ISteamServer steam, ILogger<SteamSessionService> logger)
|
||||
{
|
||||
_steam = steam;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public bool IsTicketValidForUser(string ticket, ulong steamId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(ticket))
|
||||
{
|
||||
// Caller already shouldn't pass null/empty here, but a misshaped request body
|
||||
// (e.g. wrong casing) used to NRE on the ConcurrentDictionary lookup below.
|
||||
// Fail cleanly so the auth pipeline returns 401 instead of crashing the request.
|
||||
// Mishaped request body (wrong casing on the field) used to NRE on the dictionary
|
||||
// lookup. Fail cleanly so the auth pipeline returns 401 instead of 500.
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_validatedSessionTickets.TryGetValue(ticket, out ulong storedSteamId))
|
||||
// Fast path: identical bytes from a prior validated call for this user. Real clients
|
||||
// don't replay tickets across restarts (Steam regenerates per-process), but in-process
|
||||
// retries can hit this and avoid both the Steam SDK call and the per-user lock.
|
||||
if (_validatedSessionTickets.TryGetValue(ticket, out ulong cachedSteamId))
|
||||
{
|
||||
return storedSteamId == steamId;
|
||||
return cachedSteamId == steamId;
|
||||
}
|
||||
|
||||
EnsureSteamInitialized();
|
||||
_steam.Initialize(ShadowVerseAppId);
|
||||
|
||||
List<byte> ticketBytes = new List<byte>();
|
||||
for (int i = 0; i < ticket.Length; i += 2)
|
||||
byte[] ticketBytes = HexDecode(ticket);
|
||||
|
||||
SemaphoreSlim gate = _steamIdLocks.GetOrAdd(steamId, _ => new SemaphoreSlim(1, 1));
|
||||
gate.Wait();
|
||||
try
|
||||
{
|
||||
ticketBytes.Add(Convert.ToByte(ticket.Substring(i, 2), 16));
|
||||
}
|
||||
// Re-check the cache: another caller for the same steamId may have validated this
|
||||
// exact ticket while we were waiting on the semaphore.
|
||||
if (_validatedSessionTickets.TryGetValue(ticket, out cachedSteamId))
|
||||
{
|
||||
return cachedSteamId == steamId;
|
||||
}
|
||||
|
||||
var steamCheckResults = SteamServer.BeginAuthSession(ticketBytes.ToArray(), new SteamId { Value = steamId });
|
||||
if (steamCheckResults)
|
||||
// If a different ticket is currently open for this steam id, close it first.
|
||||
// Steam's BeginAuthSession returns DuplicateRequest (which Facepunch surfaces as
|
||||
// false) when the same user already has an open session on this server — that's
|
||||
// the entire bug this whole machinery exists to fix.
|
||||
if (_activeSessionBySteamId.TryGetValue(steamId, out string? priorTicket)
|
||||
&& !string.Equals(priorTicket, ticket, StringComparison.Ordinal))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Retiring stale Steam auth session for steamId {SteamId} before opening a new one (prior ticket bytes differ).",
|
||||
steamId);
|
||||
_steam.EndSession(steamId);
|
||||
_validatedSessionTickets.TryRemove(priorTicket, out _);
|
||||
_activeSessionBySteamId.TryRemove(steamId, out _);
|
||||
}
|
||||
|
||||
bool accepted = _steam.BeginAuthSession(ticketBytes, steamId);
|
||||
if (!accepted)
|
||||
{
|
||||
_logger.LogWarning("Steam rejected BeginAuthSession for steamId {SteamId}.", steamId);
|
||||
return false;
|
||||
}
|
||||
|
||||
_validatedSessionTickets[ticket] = steamId;
|
||||
_activeSessionBySteamId[steamId] = ticket;
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_validatedSessionTickets.TryAdd(ticket, steamId);
|
||||
gate.Release();
|
||||
}
|
||||
|
||||
return steamCheckResults;
|
||||
}
|
||||
|
||||
private void EnsureSteamInitialized()
|
||||
private static byte[] HexDecode(string hex)
|
||||
{
|
||||
if (_steamInitialized) return;
|
||||
lock (_initLock)
|
||||
byte[] bytes = new byte[hex.Length / 2];
|
||||
for (int i = 0; i < bytes.Length; i++)
|
||||
{
|
||||
if (_steamInitialized) return;
|
||||
SteamServer.Init(ShadowVerseAppId, new SteamServerInit
|
||||
{
|
||||
GamePort = default,
|
||||
QueryPort = default
|
||||
});
|
||||
_steamInitialized = true;
|
||||
bytes[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_steamInitialized)
|
||||
// End every tracked session before shutting down so the Steam SDK doesn't see orphaned
|
||||
// sessions on the next process. Shutdown does this implicitly, but being explicit keeps
|
||||
// observability honest (and tests can assert it on the fake without needing Shutdown).
|
||||
foreach (var (steamId, _) in _activeSessionBySteamId)
|
||||
{
|
||||
SteamServer.Shutdown();
|
||||
try { _steam.EndSession(steamId); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "EndSession during dispose failed for steamId {SteamId}.", steamId);
|
||||
}
|
||||
}
|
||||
_activeSessionBySteamId.Clear();
|
||||
_validatedSessionTickets.Clear();
|
||||
foreach (var sem in _steamIdLocks.Values) sem.Dispose();
|
||||
_steamIdLocks.Clear();
|
||||
|
||||
try { _steam.Shutdown(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Steam shutdown failed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,5 +9,8 @@
|
||||
"ConnectionStrings": {
|
||||
"ApplicationDb": "Host=localhost;Database=svsim;Username=postgres;password=postgres"
|
||||
},
|
||||
"Deck": {
|
||||
"MaxDeckSlots": 36
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
|
||||
@@ -55,12 +55,14 @@ public class DeckControllerTests
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var decks = doc.RootElement.GetProperty("user_deck_list");
|
||||
Assert.That(decks.GetArrayLength(), Is.EqualTo(2),
|
||||
"Only Rotation-format decks should be returned for a Rotation request.");
|
||||
var names = Enumerable.Range(0, decks.GetArrayLength())
|
||||
.Select(i => decks[i].GetProperty("deck_name").GetString())
|
||||
// Real decks are tagged is_complete_deck=1; padding placeholders are 0.
|
||||
var realNames = Enumerable.Range(0, decks.GetArrayLength())
|
||||
.Select(i => decks[i])
|
||||
.Where(d => d.GetProperty("is_complete_deck").GetInt32() == 1)
|
||||
.Select(d => d.GetProperty("deck_name").GetString())
|
||||
.ToList();
|
||||
Assert.That(names, Is.EquivalentTo(new[] { "Slot 1", "Slot 2" }));
|
||||
Assert.That(realNames, Is.EquivalentTo(new[] { "Slot 1", "Slot 2" }),
|
||||
"Only Rotation-format decks should be returned for a Rotation request.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -78,8 +80,12 @@ public class DeckControllerTests
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var decks = doc.RootElement.GetProperty("user_deck_list");
|
||||
Assert.That(decks.GetArrayLength(), Is.EqualTo(1));
|
||||
Assert.That(decks[0].GetProperty("deck_name").GetString(), Is.EqualTo("Unlimited Deck"));
|
||||
var realDecks = Enumerable.Range(0, decks.GetArrayLength())
|
||||
.Select(i => decks[i])
|
||||
.Where(d => d.GetProperty("is_complete_deck").GetInt32() == 1)
|
||||
.ToList();
|
||||
Assert.That(realDecks.Count, Is.EqualTo(1));
|
||||
Assert.That(realDecks[0].GetProperty("deck_name").GetString(), Is.EqualTo("Unlimited Deck"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -96,7 +102,55 @@ public class DeckControllerTests
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var decks = doc.RootElement.GetProperty("user_deck_list");
|
||||
Assert.That(decks.GetArrayLength(), Is.EqualTo(0));
|
||||
var realDeckCount = Enumerable.Range(0, decks.GetArrayLength())
|
||||
.Count(i => decks[i].GetProperty("is_complete_deck").GetInt32() == 1);
|
||||
Assert.That(realDeckCount, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task MyList_pads_response_to_max_deck_slots_with_empty_placeholders()
|
||||
{
|
||||
// The client only renders a "New Deck" tile by converting the first response entry whose
|
||||
// card_id_array is [] into a CreateNew slot (DeckUI.DeckViewData.CreateDeckViewList).
|
||||
// Prod always pads the deck list to the per-format cap (36 in the 2026-05-23 capture)
|
||||
// with empty placeholders. Without padding, the button never appears.
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await factory.SeedDeckAsync(viewerId, Format.Rotation, 1, "Slot 1");
|
||||
await factory.SeedDeckAsync(viewerId, Format.Rotation, 2, "Slot 2");
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var response = await client.PostAsync("/deck/my_list", JsonBody(DeckFormatRequestJson(Format.Rotation)));
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var decks = doc.RootElement.GetProperty("user_deck_list");
|
||||
Assert.That(decks.GetArrayLength(), Is.EqualTo(36),
|
||||
"Response should pad to MaxDeckSlots (36) so the client can render the New Deck tile.");
|
||||
|
||||
// Real decks have is_complete_deck=1; placeholders have is_complete_deck=0. This is the
|
||||
// distinguishing marker the client itself uses (DeckData.is_complete_deck in DeckData.cs).
|
||||
var empties = Enumerable.Range(0, decks.GetArrayLength())
|
||||
.Select(i => decks[i])
|
||||
.Where(d => d.GetProperty("is_complete_deck").GetInt32() == 0)
|
||||
.ToList();
|
||||
Assert.That(empties.Count, Is.EqualTo(34),
|
||||
"Two real decks + 34 empty placeholders = 36 slots.");
|
||||
|
||||
var firstEmpty = empties[0];
|
||||
Assert.That(firstEmpty.GetProperty("deck_name").GetString(), Is.EqualTo(""));
|
||||
Assert.That(firstEmpty.GetProperty("card_id_array").GetArrayLength(), Is.EqualTo(0));
|
||||
Assert.That(firstEmpty.GetProperty("class_id").GetInt32(), Is.EqualTo(1));
|
||||
Assert.That(firstEmpty.GetProperty("sleeve_id").GetInt32(), Is.EqualTo(3000011));
|
||||
Assert.That(firstEmpty.GetProperty("is_available_deck").GetInt32(), Is.EqualTo(1));
|
||||
// Padded slot numbers must not collide with real ones, and together they must cover [1..36].
|
||||
var allDeckNos = Enumerable.Range(0, decks.GetArrayLength())
|
||||
.Select(i => decks[i].GetProperty("deck_no").GetInt32())
|
||||
.OrderBy(n => n)
|
||||
.ToList();
|
||||
Assert.That(allDeckNos, Is.EqualTo(Enumerable.Range(1, 36).ToList()));
|
||||
}
|
||||
|
||||
// ---- get_empty_deck_number ----
|
||||
@@ -234,12 +288,13 @@ public class DeckControllerTests
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var decks = doc.RootElement.GetProperty("user_deck_list");
|
||||
Assert.That(decks.GetArrayLength(), Is.EqualTo(2),
|
||||
"/deck/update should hand back the full refreshed list, saving the client a follow-up.");
|
||||
var names = Enumerable.Range(0, decks.GetArrayLength())
|
||||
.Select(i => decks[i].GetProperty("deck_name").GetString())
|
||||
var realNames = Enumerable.Range(0, decks.GetArrayLength())
|
||||
.Select(i => decks[i])
|
||||
.Where(d => d.GetProperty("is_complete_deck").GetInt32() == 1)
|
||||
.Select(d => d.GetProperty("deck_name").GetString())
|
||||
.ToList();
|
||||
Assert.That(names, Is.EquivalentTo(new[] { "Existing", "Second" }));
|
||||
Assert.That(realNames, Is.EquivalentTo(new[] { "Existing", "Second" }),
|
||||
"/deck/update should hand back the full refreshed list, saving the client a follow-up.");
|
||||
}
|
||||
|
||||
// ---- single-field mutations ----
|
||||
|
||||
216
SVSim.UnitTests/Services/SteamSessionServiceTests.cs
Normal file
216
SVSim.UnitTests/Services/SteamSessionServiceTests.cs
Normal file
@@ -0,0 +1,216 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
namespace SVSim.UnitTests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Coverage for the end-then-begin lifecycle that fixes the "401 on second client launch"
|
||||
/// bug (audit: game-start-steam-ticket-401-on-client-restart-2026-05-23). The Facepunch
|
||||
/// SDK can't be mocked directly (static + process-global state), so these tests run against
|
||||
/// the <see cref="ISteamServer"/> wrapper via <see cref="FakeSteamServer"/>, which models the
|
||||
/// part of Steam's behavior we actually care about: a second BeginAuthSession for a steamId
|
||||
/// that already has an open session is rejected unless EndSession is called first.
|
||||
/// </summary>
|
||||
public class SteamSessionServiceTests
|
||||
{
|
||||
private const ulong AliceSteamId = 76_561_198_000_000_001UL;
|
||||
private const ulong BobSteamId = 76_561_198_000_000_002UL;
|
||||
private const string TicketA = "deadbeef";
|
||||
private const string TicketB = "cafef00d";
|
||||
|
||||
[Test]
|
||||
public void IsTicketValidForUser_first_call_invokes_BeginAuthSession()
|
||||
{
|
||||
var steam = new FakeSteamServer();
|
||||
using var svc = new SteamSessionService(steam, NullLogger<SteamSessionService>.Instance);
|
||||
|
||||
Assert.That(svc.IsTicketValidForUser(TicketA, AliceSteamId), Is.True);
|
||||
Assert.That(steam.BeginCallCount, Is.EqualTo(1));
|
||||
Assert.That(steam.EndCallCount, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IsTicketValidForUser_cache_hit_skips_steam_call()
|
||||
{
|
||||
var steam = new FakeSteamServer();
|
||||
using var svc = new SteamSessionService(steam, NullLogger<SteamSessionService>.Instance);
|
||||
|
||||
Assert.That(svc.IsTicketValidForUser(TicketA, AliceSteamId), Is.True);
|
||||
Assert.That(svc.IsTicketValidForUser(TicketA, AliceSteamId), Is.True);
|
||||
Assert.That(svc.IsTicketValidForUser(TicketA, AliceSteamId), Is.True);
|
||||
|
||||
Assert.That(steam.BeginCallCount, Is.EqualTo(1), "Same ticket bytes should not re-call BeginAuthSession.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IsTicketValidForUser_cache_hit_with_wrong_steamId_returns_false()
|
||||
{
|
||||
var steam = new FakeSteamServer();
|
||||
using var svc = new SteamSessionService(steam, NullLogger<SteamSessionService>.Instance);
|
||||
|
||||
Assert.That(svc.IsTicketValidForUser(TicketA, AliceSteamId), Is.True);
|
||||
// Same ticket bytes presented for a different steamId — must reject without re-asking Steam.
|
||||
Assert.That(svc.IsTicketValidForUser(TicketA, BobSteamId), Is.False);
|
||||
Assert.That(steam.BeginCallCount, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IsTicketValidForUser_new_ticket_same_steamId_ends_prior_session_and_begins_new()
|
||||
{
|
||||
// This is the audit-doc regression: client restart → new ticket bytes → same steamId.
|
||||
// Without the end-then-begin, the second BeginAuthSession returns DuplicateRequest
|
||||
// (false in Facepunch 2.3.3) and the user gets 401 until the server restarts.
|
||||
var steam = new FakeSteamServer();
|
||||
using var svc = new SteamSessionService(steam, NullLogger<SteamSessionService>.Instance);
|
||||
|
||||
Assert.That(svc.IsTicketValidForUser(TicketA, AliceSteamId), Is.True, "first launch must succeed");
|
||||
Assert.That(svc.IsTicketValidForUser(TicketB, AliceSteamId), Is.True, "second launch must succeed (this is the bug)");
|
||||
|
||||
Assert.That(steam.BeginCallCount, Is.EqualTo(2));
|
||||
Assert.That(steam.EndCallCount, Is.EqualTo(1), "EndSession must be called between the two Begin calls");
|
||||
Assert.That(steam.EndedSteamIds.Single(), Is.EqualTo(AliceSteamId));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IsTicketValidForUser_does_not_end_session_for_different_steamId()
|
||||
{
|
||||
// Two different users authenticating shouldn't trigger any EndSession — sessions are
|
||||
// per-steamId, so Bob's login has no bearing on Alice's lease.
|
||||
var steam = new FakeSteamServer();
|
||||
using var svc = new SteamSessionService(steam, NullLogger<SteamSessionService>.Instance);
|
||||
|
||||
Assert.That(svc.IsTicketValidForUser(TicketA, AliceSteamId), Is.True);
|
||||
Assert.That(svc.IsTicketValidForUser(TicketB, BobSteamId), Is.True);
|
||||
|
||||
Assert.That(steam.EndCallCount, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IsTicketValidForUser_empty_ticket_returns_false_without_calling_steam()
|
||||
{
|
||||
var steam = new FakeSteamServer();
|
||||
using var svc = new SteamSessionService(steam, NullLogger<SteamSessionService>.Instance);
|
||||
|
||||
Assert.That(svc.IsTicketValidForUser("", AliceSteamId), Is.False);
|
||||
Assert.That(svc.IsTicketValidForUser(null!, AliceSteamId), Is.False);
|
||||
Assert.That(steam.InitializeCallCount, Is.EqualTo(0));
|
||||
Assert.That(steam.BeginCallCount, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IsTicketValidForUser_when_steam_rejects_does_not_cache()
|
||||
{
|
||||
var steam = new FakeSteamServer { RejectAllBegins = true };
|
||||
using var svc = new SteamSessionService(steam, NullLogger<SteamSessionService>.Instance);
|
||||
|
||||
Assert.That(svc.IsTicketValidForUser(TicketA, AliceSteamId), Is.False);
|
||||
// After rejection, a retry with the same ticket should ask Steam again, not return false from cache.
|
||||
Assert.That(svc.IsTicketValidForUser(TicketA, AliceSteamId), Is.False);
|
||||
Assert.That(steam.BeginCallCount, Is.EqualTo(2), "rejected tickets must not be cached");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Dispose_ends_all_active_sessions_then_shuts_down()
|
||||
{
|
||||
var steam = new FakeSteamServer();
|
||||
var svc = new SteamSessionService(steam, NullLogger<SteamSessionService>.Instance);
|
||||
svc.IsTicketValidForUser(TicketA, AliceSteamId);
|
||||
svc.IsTicketValidForUser(TicketB, BobSteamId);
|
||||
|
||||
svc.Dispose();
|
||||
|
||||
Assert.That(steam.EndedSteamIds, Is.EquivalentTo(new[] { AliceSteamId, BobSteamId }));
|
||||
Assert.That(steam.ShutdownCallCount, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Concurrent_calls_for_same_steamId_serialize_through_the_per_user_lock()
|
||||
{
|
||||
// Race: two threads call IsTicketValidForUser simultaneously with DIFFERENT tickets
|
||||
// for the same steamId. Without per-user serialization both Begins race past the
|
||||
// active-session check and one of them ends up with Steam in an inconsistent state
|
||||
// (DuplicateRequest, or worse, the wrong ticket persisted as "active"). The
|
||||
// post-condition we assert: BeginAuthSession was called twice with exactly one
|
||||
// EndSession between them — never two Begins back-to-back with no End.
|
||||
var steam = new FakeSteamServer { DelayBeginByMilliseconds = 25 };
|
||||
using var svc = new SteamSessionService(steam, NullLogger<SteamSessionService>.Instance);
|
||||
|
||||
var t1 = Task.Run(() => svc.IsTicketValidForUser(TicketA, AliceSteamId));
|
||||
var t2 = Task.Run(() => svc.IsTicketValidForUser(TicketB, AliceSteamId));
|
||||
bool[] results = await Task.WhenAll(t1, t2);
|
||||
|
||||
Assert.That(results, Is.All.True);
|
||||
Assert.That(steam.BeginCallCount, Is.EqualTo(2));
|
||||
Assert.That(steam.EndCallCount, Is.EqualTo(1));
|
||||
Assert.That(steam.OperationOrder, Is.EqualTo(new[] { "Begin", "End", "Begin" }),
|
||||
"Operations must be serialized per-steamId: Begin → (replace) → End → Begin.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory <see cref="ISteamServer"/> that models the part of Steam's behavior our
|
||||
/// production code defends against: BeginAuthSession returns false (Facepunch's
|
||||
/// collapsed representation of DuplicateRequest) when the steamId already has an open
|
||||
/// session.
|
||||
/// </summary>
|
||||
private sealed class FakeSteamServer : ISteamServer
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly HashSet<ulong> _openSessions = new();
|
||||
private readonly List<string> _operationOrder = new();
|
||||
private readonly List<ulong> _endedSteamIds = new();
|
||||
|
||||
public int InitializeCallCount { get; private set; }
|
||||
public int BeginCallCount { get; private set; }
|
||||
public int EndCallCount { get; private set; }
|
||||
public int ShutdownCallCount { get; private set; }
|
||||
public bool RejectAllBegins { get; set; }
|
||||
public int DelayBeginByMilliseconds { get; set; }
|
||||
|
||||
public IReadOnlyList<ulong> EndedSteamIds => _endedSteamIds;
|
||||
public IReadOnlyList<string> OperationOrder => _operationOrder;
|
||||
|
||||
public void Initialize(int appId)
|
||||
{
|
||||
lock (_gate) InitializeCallCount++;
|
||||
}
|
||||
|
||||
public bool BeginAuthSession(byte[] ticket, ulong steamId)
|
||||
{
|
||||
if (DelayBeginByMilliseconds > 0) Thread.Sleep(DelayBeginByMilliseconds);
|
||||
lock (_gate)
|
||||
{
|
||||
BeginCallCount++;
|
||||
_operationOrder.Add("Begin");
|
||||
if (RejectAllBegins) return false;
|
||||
if (!_openSessions.Add(steamId))
|
||||
{
|
||||
// Duplicate request — Steam's failure mode for "this steamId already
|
||||
// has an open session and you didn't EndSession first".
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public void EndSession(ulong steamId)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
EndCallCount++;
|
||||
_operationOrder.Add("End");
|
||||
_endedSteamIds.Add(steamId);
|
||||
_openSessions.Remove(steamId);
|
||||
}
|
||||
}
|
||||
|
||||
public void Shutdown()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
ShutdownCallCount++;
|
||||
_openSessions.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user