diff --git a/SVSim.Bootstrap/Importers/CardImporter.cs b/SVSim.Bootstrap/Importers/CardImporter.cs
index f5bc866..f892ec5 100644
--- a/SVSim.Bootstrap/Importers/CardImporter.cs
+++ b/SVSim.Bootstrap/Importers/CardImporter.cs
@@ -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;
diff --git a/SVSim.EmulatedEntrypoint/Configuration/DeckOptions.cs b/SVSim.EmulatedEntrypoint/Configuration/DeckOptions.cs
new file mode 100644
index 0000000..a571f69
--- /dev/null
+++ b/SVSim.EmulatedEntrypoint/Configuration/DeckOptions.cs
@@ -0,0 +1,8 @@
+namespace SVSim.EmulatedEntrypoint.Configuration;
+
+public class DeckOptions
+{
+ public const string SectionName = "Deck";
+
+ public int MaxDeckSlots { get; set; } = 36;
+}
diff --git a/SVSim.EmulatedEntrypoint/Controllers/ConfigController.cs b/SVSim.EmulatedEntrypoint/Controllers/ConfigController.cs
new file mode 100644
index 0000000..696a393
--- /dev/null
+++ b/SVSim.EmulatedEntrypoint/Controllers/ConfigController.cs
@@ -0,0 +1,31 @@
+using Microsoft.AspNetCore.Mvc;
+using SVSim.EmulatedEntrypoint.Models.Dtos.Common;
+using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
+
+namespace SVSim.EmulatedEntrypoint.Controllers;
+
+///
+/// /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).
+///
+public class ConfigController : SVSimController
+{
+ [HttpPost("update_foil_preferred")]
+ public ActionResult 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 UpdatePrizePreferred(ConfigUpdatePrizePreferredRequest request)
+ {
+ if (!TryGetViewerId(out long _)) return Unauthorized();
+ // TODO(viewer-config-persist): see UpdateFoilPreferred.
+ return new EmptyResponse();
+ }
+}
diff --git a/SVSim.EmulatedEntrypoint/Controllers/DeckController.cs b/SVSim.EmulatedEntrypoint/Controllers/DeckController.cs
index 6eb8f08..3fdb645 100644
--- a/SVSim.EmulatedEntrypoint/Controllers/DeckController.cs
+++ b/SVSim.EmulatedEntrypoint/Controllers/DeckController.cs
@@ -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)
{
_deckRepository = deckRepository;
_globalsRepository = globalsRepository;
_dbContext = dbContext;
+ _deckOptions = deckOptions.Value;
+ }
+
+ ///
+ /// Pads a viewer's real deck list with empty-slot placeholders up to .
+ /// Required because the client's DeckUI.DeckViewData.CreateDeckViewList only renders
+ /// a "New Deck" tile when the response contains an entry whose card_id_array is empty —
+ /// without padding, the player cannot create additional decks once any exist.
+ ///
+ private List PadEmptySlots(List realDecks)
+ {
+ var taken = realDecks.Select(d => d.DeckNumber).ToHashSet();
+ var result = new List(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())
};
}
diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/ConfigUpdateFoilPreferredRequest.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/ConfigUpdateFoilPreferredRequest.cs
new file mode 100644
index 0000000..50ed076
--- /dev/null
+++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/ConfigUpdateFoilPreferredRequest.cs
@@ -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; }
+}
diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/ConfigUpdatePrizePreferredRequest.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/ConfigUpdatePrizePreferredRequest.cs
new file mode 100644
index 0000000..f282654
--- /dev/null
+++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/ConfigUpdatePrizePreferredRequest.cs
@@ -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; }
+}
diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/UserDeck.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/UserDeck.cs
index 201f9ae..c56b03b 100644
--- a/SVSim.EmulatedEntrypoint/Models/Dtos/UserDeck.cs
+++ b/SVSim.EmulatedEntrypoint/Models/Dtos/UserDeck.cs
@@ -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; }
+
+ ///
+ /// Empty placeholder matching the wire shape prod uses to pad deck-list responses up to the
+ /// per-format cap. The client's DeckUI.DeckViewData.CreateDeckViewList converts the
+ /// first entry whose card_id_array is empty into the "New Deck" tile, so at least one
+ /// of these must appear in any list the player can edit.
+ ///
+ 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)
{
diff --git a/SVSim.EmulatedEntrypoint/Program.cs b/SVSim.EmulatedEntrypoint/Program.cs
index 388bd89..1c78389 100644
--- a/SVSim.EmulatedEntrypoint/Program.cs
+++ b/SVSim.EmulatedEntrypoint/Program.cs
@@ -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(builder.Configuration.GetSection(DeckOptions.SectionName));
+
#region Database Services
builder.Services.AddDbContext(opt =>
@@ -64,6 +67,7 @@ public class Program
builder.Services.AddTransient();
builder.Services.AddTransient();
builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddAuthentication()
.AddScheme(
diff --git a/SVSim.EmulatedEntrypoint/Services/FacepunchSteamServer.cs b/SVSim.EmulatedEntrypoint/Services/FacepunchSteamServer.cs
new file mode 100644
index 0000000..293a5b3
--- /dev/null
+++ b/SVSim.EmulatedEntrypoint/Services/FacepunchSteamServer.cs
@@ -0,0 +1,41 @@
+using Steamworks;
+
+namespace SVSim.EmulatedEntrypoint.Services;
+
+///
+/// Production backed by Facepunch.Steamworks 2.3.3.
+/// Initialization is process-global; registering this as a singleton is required.
+///
+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;
+ }
+}
diff --git a/SVSim.EmulatedEntrypoint/Services/ISteamServer.cs b/SVSim.EmulatedEntrypoint/Services/ISteamServer.cs
new file mode 100644
index 0000000..f30e8e0
--- /dev/null
+++ b/SVSim.EmulatedEntrypoint/Services/ISteamServer.cs
@@ -0,0 +1,34 @@
+namespace SVSim.EmulatedEntrypoint.Services;
+
+///
+/// Thin wrapper around the static Steamworks.SteamServer API. Exists purely so
+/// 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 actually invokes are exposed. Add
+/// methods here as needed rather than expanding the surface speculatively.
+///
+public interface ISteamServer
+{
+ /// Initialize the underlying Steam game server with the given app id. Idempotent.
+ void Initialize(int appId);
+
+ ///
+ /// 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 resolves by calling
+ /// first.
+ ///
+ bool BeginAuthSession(byte[] ticket, ulong steamId);
+
+ ///
+ /// 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 for
+ /// the same steam id or Steam will reject the new call as a duplicate request.
+ ///
+ void EndSession(ulong steamId);
+
+ /// Tear down the game server. Implicitly ends every open auth session.
+ void Shutdown();
+}
diff --git a/SVSim.EmulatedEntrypoint/Services/SteamSessionService.cs b/SVSim.EmulatedEntrypoint/Services/SteamSessionService.cs
index f0dc884..a52242b 100644
--- a/SVSim.EmulatedEntrypoint/Services/SteamSessionService.cs
+++ b/SVSim.EmulatedEntrypoint/Services/SteamSessionService.cs
@@ -1,74 +1,138 @@
using System.Collections.Concurrent;
-using Steamworks;
+using Microsoft.Extensions.Logging;
namespace SVSim.EmulatedEntrypoint.Services;
+///
+/// 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
+/// BeginAuthSession as a duplicate request and the auth handler returns 401.
+///
+/// See docs/audits/game-start-steam-ticket-401-on-client-restart-2026-05-23.md 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).
+///
public class SteamSessionService : IDisposable
{
- private readonly ConcurrentDictionary _validatedSessionTickets = new();
- private readonly object _initLock = new();
- private bool _steamInitialized;
-
private const int ShadowVerseAppId = 453480;
- ///
- /// Validates if a given session ticket is valid, and matches up with the given steamid.
- ///
- /// the ticket, represented as a hexadecimal string
- /// the steamid that should be associated with the ticket
- /// whether the ticket is valid for the given steamid
+ private readonly ISteamServer _steam;
+ private readonly ILogger _logger;
+
+ /// Ticket-bytes-to-steamid for cache hits on identical re-sends (e.g. retries within one client session).
+ private readonly ConcurrentDictionary _validatedSessionTickets = new();
+
+ /// steamId → currently-open ticket. Single entry per user; replaced when a new ticket supersedes it.
+ private readonly ConcurrentDictionary _activeSessionBySteamId = new();
+
+ /// Per-steamId mutex so the check-end-begin sequence is atomic for that user without serializing all auth.
+ private readonly ConcurrentDictionary _steamIdLocks = new();
+
+ public SteamSessionService(ISteamServer steam, ILogger 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 ticketBytes = new List();
- 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.");
}
}
}
diff --git a/SVSim.EmulatedEntrypoint/appsettings.json b/SVSim.EmulatedEntrypoint/appsettings.json
index 07b4b3e..353a42c 100644
--- a/SVSim.EmulatedEntrypoint/appsettings.json
+++ b/SVSim.EmulatedEntrypoint/appsettings.json
@@ -9,5 +9,8 @@
"ConnectionStrings": {
"ApplicationDb": "Host=localhost;Database=svsim;Username=postgres;password=postgres"
},
+ "Deck": {
+ "MaxDeckSlots": 36
+ },
"AllowedHosts": "*"
}
diff --git a/SVSim.UnitTests/Controllers/DeckControllerTests.cs b/SVSim.UnitTests/Controllers/DeckControllerTests.cs
index 3cab971..5deedcc 100644
--- a/SVSim.UnitTests/Controllers/DeckControllerTests.cs
+++ b/SVSim.UnitTests/Controllers/DeckControllerTests.cs
@@ -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 ----
diff --git a/SVSim.UnitTests/Services/SteamSessionServiceTests.cs b/SVSim.UnitTests/Services/SteamSessionServiceTests.cs
new file mode 100644
index 0000000..1f32a83
--- /dev/null
+++ b/SVSim.UnitTests/Services/SteamSessionServiceTests.cs
@@ -0,0 +1,216 @@
+using System.Collections.Concurrent;
+using Microsoft.Extensions.Logging.Abstractions;
+using SVSim.EmulatedEntrypoint.Services;
+
+namespace SVSim.UnitTests.Services;
+
+///
+/// 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 wrapper via , 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.
+///
+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.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.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.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.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.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.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.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.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.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.");
+ }
+
+ ///
+ /// In-memory 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.
+ ///
+ private sealed class FakeSteamServer : ISteamServer
+ {
+ private readonly object _gate = new();
+ private readonly HashSet _openSessions = new();
+ private readonly List _operationOrder = new();
+ private readonly List _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 EndedSteamIds => _endedSteamIds;
+ public IReadOnlyList 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();
+ }
+ }
+ }
+}