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);
+ }
+}