From 5aac24d2b9a2bd4b8f4f3db387dbde443c8eba75 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Thu, 28 May 2026 09:11:21 -0400 Subject: [PATCH] feat(deck-builder): /deck_code mint + /deck resolve with 3-min in-memory TTL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the portal pair (shadowverse-portal.com deck-builder endpoints) as anonymous routes on the app server. The translation middleware learns a new [NoWireEncryption] attribute that skips both AES calls but keeps the rest of the msgpack + base64 + envelope pipeline intact, matching prod's portal wire profile observed in data_dumps/traffic_prod_deckcode.ndjson. Storage is a 3-minute IMemoryCache — codes are anonymous-global, 4-char lowercase alphanumeric (matches the shortest prod sample). Foil bit is stripped on mint to match prod's normalize-on-encode behaviour. Co-Authored-By: Claude Opus 4.7 --- .../Controllers/DeckBuilderController.cs | 87 ++++++++++++++ .../NoWireEncryptionAttribute.cs | 12 ++ .../ShadowverseTranslationMiddleware.cs | 51 ++++++-- .../Models/Dtos/Common/PortalErrors.cs | 22 ++++ .../DeckBuilder/GenerateDeckCodeRequest.cs | 42 +++++++ .../DeckBuilder/GetDeckFromCodeRequest.cs | 16 +++ .../DeckBuilder/GenerateDeckCodeResponse.cs | 21 ++++ .../DeckBuilder/GetDeckFromCodeResponse.cs | 52 +++++++++ SVSim.EmulatedEntrypoint/Program.cs | 5 + .../Services/DeckCodeService.cs | 62 ++++++++++ .../Services/IDeckCodeService.cs | 17 +++ .../Controllers/DeckBuilderControllerTests.cs | 109 ++++++++++++++++++ .../Services/DeckCodeServiceTests.cs | 56 +++++++++ 13 files changed, 540 insertions(+), 12 deletions(-) create mode 100644 SVSim.EmulatedEntrypoint/Controllers/DeckBuilderController.cs create mode 100644 SVSim.EmulatedEntrypoint/Infrastructure/NoWireEncryptionAttribute.cs create mode 100644 SVSim.EmulatedEntrypoint/Models/Dtos/Common/PortalErrors.cs create mode 100644 SVSim.EmulatedEntrypoint/Models/Dtos/Requests/DeckBuilder/GenerateDeckCodeRequest.cs create mode 100644 SVSim.EmulatedEntrypoint/Models/Dtos/Requests/DeckBuilder/GetDeckFromCodeRequest.cs create mode 100644 SVSim.EmulatedEntrypoint/Models/Dtos/Responses/DeckBuilder/GenerateDeckCodeResponse.cs create mode 100644 SVSim.EmulatedEntrypoint/Models/Dtos/Responses/DeckBuilder/GetDeckFromCodeResponse.cs create mode 100644 SVSim.EmulatedEntrypoint/Services/DeckCodeService.cs create mode 100644 SVSim.EmulatedEntrypoint/Services/IDeckCodeService.cs create mode 100644 SVSim.UnitTests/Controllers/DeckBuilderControllerTests.cs create mode 100644 SVSim.UnitTests/Services/DeckCodeServiceTests.cs diff --git a/SVSim.EmulatedEntrypoint/Controllers/DeckBuilderController.cs b/SVSim.EmulatedEntrypoint/Controllers/DeckBuilderController.cs new file mode 100644 index 0000000..6620c0e --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Controllers/DeckBuilderController.cs @@ -0,0 +1,87 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SVSim.EmulatedEntrypoint.Infrastructure; +using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.DeckBuilder; +using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.DeckBuilder; +using SVSim.EmulatedEntrypoint.Services; + +namespace SVSim.EmulatedEntrypoint.Controllers; + +/// +/// Portal endpoints — deck-code mint (/deck_code) and resolve (/deck). In prod +/// these live on shadowverse-portal.com which speaks plaintext msgpack (no AES); the loader +/// redirects them to this app server via a Harmony prefix on +/// CustomPreference.GetDeckBuilderServerURL. The +/// tells the translation middleware to skip the AES wrapper for both directions. +/// +/// Deliberately does not extend : portal traffic is anonymous and +/// the routes need to live at the bare paths (/deck_code, /deck) rather than +/// under a /deckbuilder/... template. +/// +[ApiController] +[AllowAnonymous] +[NoWireEncryption] +public class DeckBuilderController : ControllerBase +{ + private readonly IDeckCodeService _codes; + + public DeckBuilderController(IDeckCodeService codes) + { + _codes = codes; + } + + [HttpPost("deck_code")] + public ActionResult Generate(GenerateDeckCodeRequest req) + { + if (req.CardID is null || req.CardID.Count == 0) + { + return new GenerateDeckCodeResponse + { + Text = "INVALID", + Errors = new() { Type = "INVALID_DECK", Message = "cardID empty" } + }; + } + + var payload = new DeckPayload + { + DeckFormat = req.DeckFormat.ToString(), + Clan = req.Clan.ToString(), + SubClan = req.SubClan ?? 0, + // Standard decks emit int 0; my-rotation decks emit the rotation id as a string. + // Mixed wire typing matches prod (data_dumps/traffic_prod_deckcode.ndjson). + RotationId = (object?)req.RotationId ?? 0, + // Strip the foil flag (ones digit) — matches prod's normalize-on-encode behaviour + // observed in the traffic dump (e.g. 703441011 → 703441010). + CardID = req.CardID.Select(id => id - (id % 10)).ToList() + }; + + string code = _codes.Mint(payload); + + return new GenerateDeckCodeResponse + { + Text = "OK", + DeckCode = code + }; + } + + [HttpPost("deck")] + public ActionResult Resolve(GetDeckFromCodeRequest req) + { + var payload = _codes.TryResolve(req.DeckCode ?? ""); + if (payload is null) + { + return new GetDeckFromCodeResponse + { + Text = "EXPIRED", + Deck = new DeckPayload(), + Errors = new() { Type = "INVALID_DECK_CODE", Message = "Unknown or expired code" } + }; + } + + return new GetDeckFromCodeResponse + { + Text = "OK", + Deck = payload + }; + } +} diff --git a/SVSim.EmulatedEntrypoint/Infrastructure/NoWireEncryptionAttribute.cs b/SVSim.EmulatedEntrypoint/Infrastructure/NoWireEncryptionAttribute.cs new file mode 100644 index 0000000..d8912c2 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Infrastructure/NoWireEncryptionAttribute.cs @@ -0,0 +1,12 @@ +namespace SVSim.EmulatedEntrypoint.Infrastructure; + +/// +/// Applied to a controller or action that speaks the same msgpack + standard envelope as the +/// rest of the game API but WITHOUT the AES wrapper. Used for endpoints hosted on +/// shadowverse-portal.com (deck builder, deck image), which use plaintext msgpack on the +/// wire — see docs/api-spec/endpoints/deck-builder/*.md. The translation middleware +/// detects the attribute and skips Encryption.Decrypt / Encryption.Encrypt; the +/// base64 wrap on the response and the msgpack ↔ JSON pivot stay the same. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = true)] +public sealed class NoWireEncryptionAttribute : Attribute { } diff --git a/SVSim.EmulatedEntrypoint/Middlewares/ShadowverseTranslationMiddleware.cs b/SVSim.EmulatedEntrypoint/Middlewares/ShadowverseTranslationMiddleware.cs index b92cd4e..cd32b34 100644 --- a/SVSim.EmulatedEntrypoint/Middlewares/ShadowverseTranslationMiddleware.cs +++ b/SVSim.EmulatedEntrypoint/Middlewares/ShadowverseTranslationMiddleware.cs @@ -11,6 +11,7 @@ using Newtonsoft.Json.Linq; using SVSim.Database.Models; using SVSim.EmulatedEntrypoint.Constants; using SVSim.EmulatedEntrypoint.Extensions; +using SVSim.EmulatedEntrypoint.Infrastructure; using SVSim.EmulatedEntrypoint.Models.Dtos; using SVSim.EmulatedEntrypoint.Models.Dtos.Internal; using SVSim.EmulatedEntrypoint.Security; @@ -60,6 +61,19 @@ public class ShadowverseTranslationMiddleware : IMiddleware return; } + // Portal endpoints (shadowverse-portal.com — deck builder, deck image) speak msgpack + // and the standard envelope but skip AES on the wire. Detect via [NoWireEncryption] on + // the controller or action; this flag toggles the two Encryption calls below but every + // other step (msgpack pivot, JSON re-serialize for the binder, envelope wrap, base64 of + // the response) stays identical. + bool skipEncryption = false; + if (endpointDescriptor is ControllerActionDescriptor cad) + { + skipEncryption = + cad.MethodInfo.GetCustomAttributes(typeof(NoWireEncryptionAttribute), inherit: true).Length > 0 || + cad.ControllerTypeInfo.GetCustomAttributes(typeof(NoWireEncryptionAttribute), inherit: true).Length > 0; + } + // Replace response body stream to re-access it. using MemoryStream tempResponseBody = new MemoryStream(); Stream originalResponsebody = context.Response.Body; @@ -70,10 +84,12 @@ public class ShadowverseTranslationMiddleware : IMiddleware await context.Request.Body.CopyToAsync(requestBytesStream); byte[] requestBytes = requestBytesStream.ToArray(); - // Get encryption values for this request + // Get encryption values for this request. Portal endpoints don't carry a SID/UDID pair + // (they're anonymous-on-the-wire), so the lookup is skipped on the skip-encryption path + // — there's nothing to decrypt against. string sid = context.Request.Headers[NetworkConstants.SessionIdHeaderName]; - Guid? mappedUdid = _sessionService.GetUdidFromSessionId(sid); - if (mappedUdid is null) + Guid? mappedUdid = skipEncryption ? null : _sessionService.GetUdidFromSessionId(sid); + if (mappedUdid is null && !skipEncryption) { // Per design (2026-05-25): warn and continue. Decrypt will fail with Guid.Empty as // the AES key, surfacing as a msgpack/decrypt error below — but now the *root cause* @@ -85,11 +101,13 @@ public class ShadowverseTranslationMiddleware : IMiddleware } string udid = mappedUdid.GetValueOrDefault().ToString(); - // Decrypt incoming data. + // Decrypt incoming data — unless this is a [NoWireEncryption] endpoint, in which case + // the request body is already raw msgpack (the client sends portal requests via + // _createBodyMsgpack with encrypt=false). byte[] decryptedBytes; try { - decryptedBytes = Encryption.Decrypt(requestBytes, udid); + decryptedBytes = skipEncryption ? requestBytes : Encryption.Decrypt(requestBytes, udid); } catch (Exception ex) { @@ -155,7 +173,9 @@ public class ShadowverseTranslationMiddleware : IMiddleware ? null : ConvertJsonTreeToPlainObject(JToken.Parse(responseJson)); - // Wrap the response in a datawrapper + // Wrap the response in a datawrapper. Portal (no-encryption) endpoints emit an anonymous + // envelope — viewer/udid/sid stay zero/empty — matching the prod portal traffic shape + // captured in data_dumps/traffic_prod_deckcode.ndjson. DataWrapper wrappedResponseData = new DataWrapper { Data = responseData, @@ -177,12 +197,12 @@ public class ShadowverseTranslationMiddleware : IMiddleware // failed to find a Steam-linked viewer. The wire still needs short_udid / viewer_id // populated (prod sends real numbers for the title check too, but 0 / 0 satisfies // the client's BaseTask.Parse which only reads result_code + servertime here). - ShortUdid = viewer?.ShortUdid ?? 0, - ViewerId = viewer?.Id ?? 0, + ShortUdid = skipEncryption ? 0 : (viewer?.ShortUdid ?? 0), + ViewerId = skipEncryption ? 0 : (viewer?.Id ?? 0), // Echo the decrypted-against UDID. Most clients ignore this field; SignUpTask.Parse // requires it (validates against Certification.Udid on the response). Comes from // mappedUdid (the value used for AES); never from controller state. - Udid = mappedUdid?.ToString() ?? "" + Udid = skipEncryption ? "" : (mappedUdid?.ToString() ?? "") } }; @@ -191,17 +211,24 @@ public class ShadowverseTranslationMiddleware : IMiddleware // primitive tree under Data — emitting only the keys present in the dictionary. var msgPackOptions = MessagePackSerializerOptions.Standard .WithResolver(ContractlessStandardResolver.Instance); + // Both branches base64-wrap the response body — the client's NetworkManager.Connect + // reads downloadHandler.text and calls Convert.FromBase64String on the no-encryption + // path (Cute/NetworkManager.cs:194) and CryptAES.decrypt (which also base64-decodes + // internally) on the encrypted path. byte[] packedData; try { packedData = MessagePackSerializer.Serialize(wrappedResponseData, msgPackOptions); - packedData = Encryption.Encrypt(packedData, udid); + if (!skipEncryption) + { + packedData = Encryption.Encrypt(packedData, udid); + } } catch (Exception ex) { _logger.LogError(ex, - "Response msgpack/encrypt failed for {Path} (viewerId={ViewerId}, udid={Udid}).", - path, viewer?.Id, udid); + "Response msgpack{EncryptStep} failed for {Path} (viewerId={ViewerId}, udid={Udid}).", + skipEncryption ? "" : "/encrypt", path, viewer?.Id, udid); throw; } await originalResponsebody.WriteAsync(Encoding.UTF8.GetBytes(Convert.ToBase64String(packedData))); diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Common/PortalErrors.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Common/PortalErrors.cs new file mode 100644 index 0000000..2470c9b --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Common/PortalErrors.cs @@ -0,0 +1,22 @@ +using MessagePack; +using System.Text.Json.Serialization; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Common; + +/// +/// The portal (shadowverse-portal.com) wraps every response with an `errors` object that is +/// present even on success — the success-path payload carries a stub `UNKNOWN_ERROR` / "error +/// message" pair that the client ignores when result_code == 1. See +/// docs/api-spec/endpoints/deck-builder/*.md. +/// +[MessagePackObject] +public class PortalErrors +{ + [JsonPropertyName("type")] + [Key("type")] + public string Type { get; set; } = "UNKNOWN_ERROR"; + + [JsonPropertyName("message")] + [Key("message")] + public string Message { get; set; } = ""; +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/DeckBuilder/GenerateDeckCodeRequest.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/DeckBuilder/GenerateDeckCodeRequest.cs new file mode 100644 index 0000000..4a28ada --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/DeckBuilder/GenerateDeckCodeRequest.cs @@ -0,0 +1,42 @@ +using MessagePack; +using System.Text.Json.Serialization; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.DeckBuilder; + +/// +/// Covers all three client-side overloads of GenerateDeckCodeTask.SetParameter: +/// standard, crossover (sub_clan present), and my-rotation (rotation_id present, no phantom). +/// Optional fields stay null on shapes that don't carry them. +/// +/// Deliberately does NOT inherit from : portal endpoints are anonymous +/// (the server ignores viewer_id / steam_id / steam_session_ticket on the wire — see the +/// data_headers in the prod traffic dump where they're all zeroed). The fields still arrive on +/// the wire from the client; System.Text.Json silently drops unknown JSON properties. +/// +[MessagePackObject] +public class GenerateDeckCodeRequest +{ + [JsonPropertyName("clan")] + [Key("clan")] + public int Clan { get; set; } + + [JsonPropertyName("sub_clan")] + [Key("sub_clan")] + public int? SubClan { get; set; } + + [JsonPropertyName("deck_format")] + [Key("deck_format")] + public int DeckFormat { get; set; } + + [JsonPropertyName("card_id")] + [Key("card_id")] + public List CardID { get; set; } = new(); + + [JsonPropertyName("phantom_card_id")] + [Key("phantom_card_id")] + public List? PhantomCardID { get; set; } + + [JsonPropertyName("rotation_id")] + [Key("rotation_id")] + public string? RotationId { get; set; } +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/DeckBuilder/GetDeckFromCodeRequest.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/DeckBuilder/GetDeckFromCodeRequest.cs new file mode 100644 index 0000000..7d0e2d7 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/DeckBuilder/GetDeckFromCodeRequest.cs @@ -0,0 +1,16 @@ +using MessagePack; +using System.Text.Json.Serialization; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.DeckBuilder; + +/// +/// Portal resolve-by-code request. Anonymous on the wire — does not extend +/// ; see for the rationale. +/// +[MessagePackObject] +public class GetDeckFromCodeRequest +{ + [JsonPropertyName("deck_code")] + [Key("deck_code")] + public string DeckCode { get; set; } = ""; +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/DeckBuilder/GenerateDeckCodeResponse.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/DeckBuilder/GenerateDeckCodeResponse.cs new file mode 100644 index 0000000..668d546 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/DeckBuilder/GenerateDeckCodeResponse.cs @@ -0,0 +1,21 @@ +using MessagePack; +using SVSim.EmulatedEntrypoint.Models.Dtos.Common; +using System.Text.Json.Serialization; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.DeckBuilder; + +[MessagePackObject] +public class GenerateDeckCodeResponse +{ + [JsonPropertyName("text")] + [Key("text")] + public string Text { get; set; } = "OK"; + + [JsonPropertyName("deck_code")] + [Key("deck_code")] + public string DeckCode { get; set; } = ""; + + [JsonPropertyName("errors")] + [Key("errors")] + public PortalErrors Errors { get; set; } = new(); +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/DeckBuilder/GetDeckFromCodeResponse.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/DeckBuilder/GetDeckFromCodeResponse.cs new file mode 100644 index 0000000..a52a043 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/DeckBuilder/GetDeckFromCodeResponse.cs @@ -0,0 +1,52 @@ +using MessagePack; +using SVSim.EmulatedEntrypoint.Models.Dtos.Common; +using System.Text.Json.Serialization; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.DeckBuilder; + +[MessagePackObject] +public class GetDeckFromCodeResponse +{ + [JsonPropertyName("text")] + [Key("text")] + public string Text { get; set; } = "OK"; + + [JsonPropertyName("deck")] + [Key("deck")] + public DeckPayload Deck { get; set; } = new(); + + [JsonPropertyName("errors")] + [Key("errors")] + public PortalErrors Errors { get; set; } = new(); +} + +/// +/// Wire shape inside the deck envelope. Prod emits clan / deck_format as +/// strings but sub_clan / rotation_id as ints — mirror that quirk so the client +/// `.ToInt()` / `.ToString()` paths see what they expect. RotationId is typed as +/// object so we can emit the int literal 0 on standard decks (matches prod) and a +/// string on MyRotation decks. +/// +[MessagePackObject] +public class DeckPayload +{ + [JsonPropertyName("deck_format")] + [Key("deck_format")] + public string DeckFormat { get; set; } = "1"; + + [JsonPropertyName("clan")] + [Key("clan")] + public string Clan { get; set; } = "0"; + + [JsonPropertyName("sub_clan")] + [Key("sub_clan")] + public int SubClan { get; set; } + + [JsonPropertyName("rotation_id")] + [Key("rotation_id")] + public object RotationId { get; set; } = 0; + + [JsonPropertyName("card_id")] + [Key("card_id")] + public List CardID { get; set; } = new(); +} diff --git a/SVSim.EmulatedEntrypoint/Program.cs b/SVSim.EmulatedEntrypoint/Program.cs index cd2a3b7..9d26596 100644 --- a/SVSim.EmulatedEntrypoint/Program.cs +++ b/SVSim.EmulatedEntrypoint/Program.cs @@ -102,6 +102,11 @@ public class Program builder.Services.AddSingleton(); builder.Services.AddSingleton(); + // Deck-code mint/resolve uses IMemoryCache for ephemeral (3-min TTL) storage; no DB + // row, no migration. Singleton because the cache + RNG seam are process-wide. + builder.Services.AddMemoryCache(); + builder.Services.AddSingleton(); + #endregion builder.Services.AddTransient(); diff --git a/SVSim.EmulatedEntrypoint/Services/DeckCodeService.cs b/SVSim.EmulatedEntrypoint/Services/DeckCodeService.cs new file mode 100644 index 0000000..5753ac1 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Services/DeckCodeService.cs @@ -0,0 +1,62 @@ +using Microsoft.Extensions.Caching.Memory; +using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.DeckBuilder; + +namespace SVSim.EmulatedEntrypoint.Services; + +/// +/// In-memory deck-code store with a 3-minute absolute TTL. Codes are lowercase 4-character +/// alphanumeric tokens — matches the shortest sample observed in prod (e.g. "t7rz" in +/// data_dumps/traffic_prod_deckcode.ndjson). The portal's anonymous global namespace is +/// mirrored here: codes are not scoped to viewer. +/// +public sealed class DeckCodeService : IDeckCodeService +{ + public static readonly TimeSpan Ttl = TimeSpan.FromMinutes(3); + + private const string Alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"; + private const int CodeLength = 4; // 36^4 ≈ 1.7M codes + private const int MaxMintAttempts = 8; // collision retries — saturation is genuinely exceptional + + private readonly IMemoryCache _cache; + private readonly IRandom _random; + + public DeckCodeService(IMemoryCache cache, IRandom random) + { + _cache = cache; + _random = random; + } + + public string Mint(DeckPayload payload) + { + for (int attempt = 0; attempt < MaxMintAttempts; attempt++) + { + string code = GenerateCode(); + string key = CacheKey(code); + if (_cache.TryGetValue(key, out _)) continue; + + _cache.Set(key, payload, Ttl); + return code; + } + + // Hit only if the 4-char namespace is genuinely saturated within a 3-minute window. + // At that load we'd want longer codes; throw loudly so the symptom doesn't get buried. + throw new InvalidOperationException( + $"Deck-code namespace saturated after {MaxMintAttempts} attempts. " + + "Either traffic exploded or the cache is misconfigured."); + } + + public DeckPayload? TryResolve(string code) + => _cache.TryGetValue(CacheKey(code), out var payload) ? payload : null; + + private string GenerateCode() + { + Span buf = stackalloc char[CodeLength]; + for (int i = 0; i < CodeLength; i++) + { + buf[i] = Alphabet[_random.Next(Alphabet.Length)]; + } + return new string(buf); + } + + internal static string CacheKey(string code) => $"deck_code:{code}"; +} diff --git a/SVSim.EmulatedEntrypoint/Services/IDeckCodeService.cs b/SVSim.EmulatedEntrypoint/Services/IDeckCodeService.cs new file mode 100644 index 0000000..190b095 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Services/IDeckCodeService.cs @@ -0,0 +1,17 @@ +using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.DeckBuilder; + +namespace SVSim.EmulatedEntrypoint.Services; + +public interface IDeckCodeService +{ + /// + /// Stores under a freshly minted token and returns it. The token + /// is valid for from this call. + /// + string Mint(DeckPayload payload); + + /// + /// Returns the deck payload for an unexpired code, or null on miss/expired. + /// + DeckPayload? TryResolve(string code); +} diff --git a/SVSim.UnitTests/Controllers/DeckBuilderControllerTests.cs b/SVSim.UnitTests/Controllers/DeckBuilderControllerTests.cs new file mode 100644 index 0000000..f93cd79 --- /dev/null +++ b/SVSim.UnitTests/Controllers/DeckBuilderControllerTests.cs @@ -0,0 +1,109 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.DeckBuilder; +using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.DeckBuilder; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Controllers; + +/// +/// End-to-end coverage for the portal pair (/deck_code, /deck). These tests bypass the +/// translation middleware (non-Unity UA) and hit the controllers via plain JSON, which is fine +/// — both endpoints are anonymous and the action signatures don't care which path serialized +/// the body. The middleware's [NoWireEncryption] branch is exercised in the live smoke test. +/// +public class DeckBuilderControllerTests +{ + private static readonly JsonSerializerOptions Json = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower + }; + + [Test] + public async Task Generate_then_resolve_roundtrips_deck_payload() + { + using var factory = new SVSimTestFactory(); + using var client = factory.CreateClient(); + + var generate = await client.PostAsJsonAsync("/deck_code", + new GenerateDeckCodeRequest + { + Clan = 4, + DeckFormat = 1, + CardID = new() { 100414020, 100414020, 104021030 } + }, Json); + + Assert.That(generate.StatusCode, Is.EqualTo(HttpStatusCode.OK), + await generate.Content.ReadAsStringAsync()); + var generateBody = await generate.Content.ReadFromJsonAsync(Json); + Assert.That(generateBody, Is.Not.Null); + Assert.That(generateBody!.DeckCode, Has.Length.EqualTo(4)); + + var resolve = await client.PostAsJsonAsync("/deck", + new GetDeckFromCodeRequest { DeckCode = generateBody.DeckCode }, Json); + Assert.That(resolve.StatusCode, Is.EqualTo(HttpStatusCode.OK), + await resolve.Content.ReadAsStringAsync()); + + var resolveBody = await resolve.Content.ReadFromJsonAsync(Json); + Assert.That(resolveBody, Is.Not.Null); + Assert.That(resolveBody!.Deck.Clan, Is.EqualTo("4")); + Assert.That(resolveBody.Deck.DeckFormat, Is.EqualTo("1")); + Assert.That(resolveBody.Deck.SubClan, Is.EqualTo(0)); + Assert.That(resolveBody.Deck.CardID, Is.EqualTo(new List { 100414020, 100414020, 104021030 })); + } + + [Test] + public async Task Generate_strips_foil_flag_from_card_ids() + { + using var factory = new SVSimTestFactory(); + using var client = factory.CreateClient(); + + var generate = await client.PostAsJsonAsync("/deck_code", + new GenerateDeckCodeRequest + { + Clan = 4, + DeckFormat = 1, + // 011 ids are foil variants observed in the prod traffic dump. + CardID = new() { 703441011, 701441011, 100414020 } + }, Json); + var generateBody = await generate.Content.ReadFromJsonAsync(Json); + + var resolve = await client.PostAsJsonAsync("/deck", + new GetDeckFromCodeRequest { DeckCode = generateBody!.DeckCode }, Json); + var resolveBody = await resolve.Content.ReadFromJsonAsync(Json); + + Assert.That(resolveBody!.Deck.CardID, + Is.EqualTo(new List { 703441010, 701441010, 100414020 }), + "Foil bit (last digit) must be normalized to 0 in the stored payload."); + } + + [Test] + public async Task Resolve_returns_invalid_code_error_for_unknown_code() + { + using var factory = new SVSimTestFactory(); + using var client = factory.CreateClient(); + + var resolve = await client.PostAsJsonAsync("/deck", + new GetDeckFromCodeRequest { DeckCode = "zzzz" }, Json); + Assert.That(resolve.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + + var body = await resolve.Content.ReadFromJsonAsync(Json); + Assert.That(body!.Errors.Type, Is.EqualTo("INVALID_DECK_CODE")); + } + + [Test] + public async Task Generate_rejects_empty_card_list() + { + using var factory = new SVSimTestFactory(); + using var client = factory.CreateClient(); + + var generate = await client.PostAsJsonAsync("/deck_code", + new GenerateDeckCodeRequest { Clan = 1, DeckFormat = 1, CardID = new() }, Json); + var body = await generate.Content.ReadFromJsonAsync(Json); + + Assert.That(body!.Errors.Type, Is.EqualTo("INVALID_DECK")); + Assert.That(body.DeckCode, Is.Empty); + } +} diff --git a/SVSim.UnitTests/Services/DeckCodeServiceTests.cs b/SVSim.UnitTests/Services/DeckCodeServiceTests.cs new file mode 100644 index 0000000..624b4b6 --- /dev/null +++ b/SVSim.UnitTests/Services/DeckCodeServiceTests.cs @@ -0,0 +1,56 @@ +using Microsoft.Extensions.Caching.Memory; +using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.DeckBuilder; +using SVSim.EmulatedEntrypoint.Services; + +namespace SVSim.UnitTests.Services; + +public class DeckCodeServiceTests +{ + private static DeckCodeService NewService(out IMemoryCache cache, IRandom? random = null) + { + cache = new MemoryCache(new MemoryCacheOptions()); + return new DeckCodeService(cache, random ?? new SystemRandom()); + } + + [Test] + public void Mint_returns_4char_lowercase_alphanumeric_code() + { + var svc = NewService(out _); + + var code = svc.Mint(new DeckPayload { Clan = "1", CardID = new() { 100211010 } }); + + Assert.That(code, Has.Length.EqualTo(4)); + Assert.That(code, Does.Match("^[a-z0-9]+$")); + } + + [Test] + public void Resolve_returns_payload_when_code_unexpired() + { + var svc = NewService(out _); + var original = new DeckPayload { Clan = "4", CardID = new() { 100414020, 100414020 } }; + + var code = svc.Mint(original); + var resolved = svc.TryResolve(code); + + Assert.That(resolved, Is.SameAs(original)); + } + + [Test] + public void Resolve_returns_null_for_unknown_code() + { + var svc = NewService(out _); + + Assert.That(svc.TryResolve("nope"), Is.Null); + } + + [Test] + public void Resolve_returns_null_after_cache_eviction() + { + // Don't sleep for the 3-minute TTL — drop the entry directly to simulate expiry. + var svc = NewService(out var cache); + var code = svc.Mint(new DeckPayload { Clan = "1", CardID = new() { 100211010 } }); + cache.Remove(DeckCodeService.CacheKey(code)); + + Assert.That(svc.TryResolve(code), Is.Null); + } +}