feat(deck-builder): /deck_code mint + /deck resolve with 3-min in-memory TTL

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 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-05-28 09:11:21 -04:00
parent 71b0c66631
commit 5aac24d2b9
13 changed files with 540 additions and 12 deletions

View File

@@ -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;
/// <summary>
/// Portal endpoints — deck-code mint (<c>/deck_code</c>) and resolve (<c>/deck</c>). 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
/// <c>CustomPreference.GetDeckBuilderServerURL</c>. The <see cref="NoWireEncryptionAttribute"/>
/// tells the translation middleware to skip the AES wrapper for both directions.
///
/// Deliberately does not extend <see cref="SVSimController"/>: portal traffic is anonymous and
/// the routes need to live at the bare paths (<c>/deck_code</c>, <c>/deck</c>) rather than
/// under a <c>/deckbuilder/...</c> template.
/// </summary>
[ApiController]
[AllowAnonymous]
[NoWireEncryption]
public class DeckBuilderController : ControllerBase
{
private readonly IDeckCodeService _codes;
public DeckBuilderController(IDeckCodeService codes)
{
_codes = codes;
}
[HttpPost("deck_code")]
public ActionResult<GenerateDeckCodeResponse> 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<GetDeckFromCodeResponse> 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
};
}
}

View File

@@ -0,0 +1,12 @@
namespace SVSim.EmulatedEntrypoint.Infrastructure;
/// <summary>
/// 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
/// <c>shadowverse-portal.com</c> (deck builder, deck image), which use plaintext msgpack on the
/// wire — see <c>docs/api-spec/endpoints/deck-builder/*.md</c>. The translation middleware
/// detects the attribute and skips <c>Encryption.Decrypt</c> / <c>Encryption.Encrypt</c>; the
/// base64 wrap on the response and the msgpack ↔ JSON pivot stay the same.
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = true)]
public sealed class NoWireEncryptionAttribute : Attribute { }

View File

@@ -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);
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)));

View File

@@ -0,0 +1,22 @@
using MessagePack;
using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Common;
/// <summary>
/// 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
/// <c>docs/api-spec/endpoints/deck-builder/*.md</c>.
/// </summary>
[MessagePackObject]
public class PortalErrors
{
[JsonPropertyName("type")]
[Key("type")]
public string Type { get; set; } = "UNKNOWN_ERROR";
[JsonPropertyName("message")]
[Key("message")]
public string Message { get; set; } = "";
}

View File

@@ -0,0 +1,42 @@
using MessagePack;
using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.DeckBuilder;
/// <summary>
/// Covers all three client-side overloads of <c>GenerateDeckCodeTask.SetParameter</c>:
/// 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 <see cref="BaseRequest"/>: 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.
/// </summary>
[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<long> CardID { get; set; } = new();
[JsonPropertyName("phantom_card_id")]
[Key("phantom_card_id")]
public List<long>? PhantomCardID { get; set; }
[JsonPropertyName("rotation_id")]
[Key("rotation_id")]
public string? RotationId { get; set; }
}

View File

@@ -0,0 +1,16 @@
using MessagePack;
using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.DeckBuilder;
/// <summary>
/// Portal resolve-by-code request. Anonymous on the wire — does not extend
/// <see cref="BaseRequest"/>; see <see cref="GenerateDeckCodeRequest"/> for the rationale.
/// </summary>
[MessagePackObject]
public class GetDeckFromCodeRequest
{
[JsonPropertyName("deck_code")]
[Key("deck_code")]
public string DeckCode { get; set; } = "";
}

View File

@@ -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();
}

View File

@@ -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();
}
/// <summary>
/// Wire shape inside the <c>deck</c> envelope. Prod emits <c>clan</c> / <c>deck_format</c> as
/// strings but <c>sub_clan</c> / <c>rotation_id</c> as ints — mirror that quirk so the client
/// `.ToInt()` / `.ToString()` paths see what they expect. <c>RotationId</c> is typed as
/// <c>object</c> so we can emit the int literal <c>0</c> on standard decks (matches prod) and a
/// string on MyRotation decks.
/// </summary>
[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<long> CardID { get; set; } = new();
}

View File

@@ -102,6 +102,11 @@ public class Program
builder.Services.AddSingleton<IRandom, SystemRandom>();
builder.Services.AddSingleton<PuzzleMissionEvaluator>();
// 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<IDeckCodeService, DeckCodeService>();
#endregion
builder.Services.AddTransient<ShadowverseTranslationMiddleware>();

View File

@@ -0,0 +1,62 @@
using Microsoft.Extensions.Caching.Memory;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.DeckBuilder;
namespace SVSim.EmulatedEntrypoint.Services;
/// <summary>
/// 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.
/// </summary>
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<DeckPayload>(CacheKey(code), out var payload) ? payload : null;
private string GenerateCode()
{
Span<char> 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}";
}

View File

@@ -0,0 +1,17 @@
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.DeckBuilder;
namespace SVSim.EmulatedEntrypoint.Services;
public interface IDeckCodeService
{
/// <summary>
/// Stores <paramref name="payload"/> under a freshly minted token and returns it. The token
/// is valid for <see cref="DeckCodeService.Ttl"/> from this call.
/// </summary>
string Mint(DeckPayload payload);
/// <summary>
/// Returns the deck payload for an unexpired code, or null on miss/expired.
/// </summary>
DeckPayload? TryResolve(string code);
}

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
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<GenerateDeckCodeResponse>(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<GetDeckFromCodeResponse>(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<long> { 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<GenerateDeckCodeResponse>(Json);
var resolve = await client.PostAsJsonAsync("/deck",
new GetDeckFromCodeRequest { DeckCode = generateBody!.DeckCode }, Json);
var resolveBody = await resolve.Content.ReadFromJsonAsync<GetDeckFromCodeResponse>(Json);
Assert.That(resolveBody!.Deck.CardID,
Is.EqualTo(new List<long> { 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<GetDeckFromCodeResponse>(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<GenerateDeckCodeResponse>(Json);
Assert.That(body!.Errors.Type, Is.EqualTo("INVALID_DECK"));
Assert.That(body.DeckCode, Is.Empty);
}
}

View File

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