From bdff142d16c32ca24658ab1fc8dd5a574a2cfa22 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 24 May 2026 00:17:28 -0400 Subject: [PATCH] Practice/deck editing mostly there --- SVSim.Bootstrap/Importers/CardImporter.cs | 2 +- .../Configuration/DeckOptions.cs | 8 + .../Controllers/ConfigController.cs | 31 +++ .../Controllers/DeckController.cs | 36 ++- .../ConfigUpdateFoilPreferredRequest.cs | 12 + .../ConfigUpdatePrizePreferredRequest.cs | 12 + .../Models/Dtos/UserDeck.cs | 27 ++- SVSim.EmulatedEntrypoint/Program.cs | 4 + .../Services/FacepunchSteamServer.cs | 41 ++++ .../Services/ISteamServer.cs | 34 +++ .../Services/SteamSessionService.cs | 140 +++++++++--- SVSim.EmulatedEntrypoint/appsettings.json | 3 + .../Controllers/DeckControllerTests.cs | 81 +++++-- .../Services/SteamSessionServiceTests.cs | 216 ++++++++++++++++++ 14 files changed, 588 insertions(+), 59 deletions(-) create mode 100644 SVSim.EmulatedEntrypoint/Configuration/DeckOptions.cs create mode 100644 SVSim.EmulatedEntrypoint/Controllers/ConfigController.cs create mode 100644 SVSim.EmulatedEntrypoint/Models/Dtos/Requests/ConfigUpdateFoilPreferredRequest.cs create mode 100644 SVSim.EmulatedEntrypoint/Models/Dtos/Requests/ConfigUpdatePrizePreferredRequest.cs create mode 100644 SVSim.EmulatedEntrypoint/Services/FacepunchSteamServer.cs create mode 100644 SVSim.EmulatedEntrypoint/Services/ISteamServer.cs create mode 100644 SVSim.UnitTests/Services/SteamSessionServiceTests.cs 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(); + } + } + } +}