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:
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { }
|
||||||
@@ -11,6 +11,7 @@ using Newtonsoft.Json.Linq;
|
|||||||
using SVSim.Database.Models;
|
using SVSim.Database.Models;
|
||||||
using SVSim.EmulatedEntrypoint.Constants;
|
using SVSim.EmulatedEntrypoint.Constants;
|
||||||
using SVSim.EmulatedEntrypoint.Extensions;
|
using SVSim.EmulatedEntrypoint.Extensions;
|
||||||
|
using SVSim.EmulatedEntrypoint.Infrastructure;
|
||||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Internal;
|
using SVSim.EmulatedEntrypoint.Models.Dtos.Internal;
|
||||||
using SVSim.EmulatedEntrypoint.Security;
|
using SVSim.EmulatedEntrypoint.Security;
|
||||||
@@ -60,6 +61,19 @@ public class ShadowverseTranslationMiddleware : IMiddleware
|
|||||||
return;
|
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.
|
// Replace response body stream to re-access it.
|
||||||
using MemoryStream tempResponseBody = new MemoryStream();
|
using MemoryStream tempResponseBody = new MemoryStream();
|
||||||
Stream originalResponsebody = context.Response.Body;
|
Stream originalResponsebody = context.Response.Body;
|
||||||
@@ -70,10 +84,12 @@ public class ShadowverseTranslationMiddleware : IMiddleware
|
|||||||
await context.Request.Body.CopyToAsync(requestBytesStream);
|
await context.Request.Body.CopyToAsync(requestBytesStream);
|
||||||
byte[] requestBytes = requestBytesStream.ToArray();
|
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];
|
string sid = context.Request.Headers[NetworkConstants.SessionIdHeaderName];
|
||||||
Guid? mappedUdid = _sessionService.GetUdidFromSessionId(sid);
|
Guid? mappedUdid = skipEncryption ? null : _sessionService.GetUdidFromSessionId(sid);
|
||||||
if (mappedUdid is null)
|
if (mappedUdid is null && !skipEncryption)
|
||||||
{
|
{
|
||||||
// Per design (2026-05-25): warn and continue. Decrypt will fail with Guid.Empty as
|
// 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*
|
// 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();
|
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;
|
byte[] decryptedBytes;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
decryptedBytes = Encryption.Decrypt(requestBytes, udid);
|
decryptedBytes = skipEncryption ? requestBytes : Encryption.Decrypt(requestBytes, udid);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -155,7 +173,9 @@ public class ShadowverseTranslationMiddleware : IMiddleware
|
|||||||
? null
|
? null
|
||||||
: ConvertJsonTreeToPlainObject(JToken.Parse(responseJson));
|
: 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
|
DataWrapper wrappedResponseData = new DataWrapper
|
||||||
{
|
{
|
||||||
Data = responseData,
|
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
|
// 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
|
// 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).
|
// the client's BaseTask.Parse which only reads result_code + servertime here).
|
||||||
ShortUdid = viewer?.ShortUdid ?? 0,
|
ShortUdid = skipEncryption ? 0 : (viewer?.ShortUdid ?? 0),
|
||||||
ViewerId = viewer?.Id ?? 0,
|
ViewerId = skipEncryption ? 0 : (viewer?.Id ?? 0),
|
||||||
// Echo the decrypted-against UDID. Most clients ignore this field; SignUpTask.Parse
|
// Echo the decrypted-against UDID. Most clients ignore this field; SignUpTask.Parse
|
||||||
// requires it (validates against Certification.Udid on the response). Comes from
|
// requires it (validates against Certification.Udid on the response). Comes from
|
||||||
// mappedUdid (the value used for AES); never from controller state.
|
// 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.
|
// primitive tree under Data — emitting only the keys present in the dictionary.
|
||||||
var msgPackOptions = MessagePackSerializerOptions.Standard
|
var msgPackOptions = MessagePackSerializerOptions.Standard
|
||||||
.WithResolver(ContractlessStandardResolver.Instance);
|
.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;
|
byte[] packedData;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
packedData = MessagePackSerializer.Serialize(wrappedResponseData, msgPackOptions);
|
packedData = MessagePackSerializer.Serialize(wrappedResponseData, msgPackOptions);
|
||||||
packedData = Encryption.Encrypt(packedData, udid);
|
if (!skipEncryption)
|
||||||
|
{
|
||||||
|
packedData = Encryption.Encrypt(packedData, udid);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex,
|
_logger.LogError(ex,
|
||||||
"Response msgpack/encrypt failed for {Path} (viewerId={ViewerId}, udid={Udid}).",
|
"Response msgpack{EncryptStep} failed for {Path} (viewerId={ViewerId}, udid={Udid}).",
|
||||||
path, viewer?.Id, udid);
|
skipEncryption ? "" : "/encrypt", path, viewer?.Id, udid);
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
await originalResponsebody.WriteAsync(Encoding.UTF8.GetBytes(Convert.ToBase64String(packedData)));
|
await originalResponsebody.WriteAsync(Encoding.UTF8.GetBytes(Convert.ToBase64String(packedData)));
|
||||||
|
|||||||
22
SVSim.EmulatedEntrypoint/Models/Dtos/Common/PortalErrors.cs
Normal file
22
SVSim.EmulatedEntrypoint/Models/Dtos/Common/PortalErrors.cs
Normal 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; } = "";
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; } = "";
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -102,6 +102,11 @@ public class Program
|
|||||||
builder.Services.AddSingleton<IRandom, SystemRandom>();
|
builder.Services.AddSingleton<IRandom, SystemRandom>();
|
||||||
builder.Services.AddSingleton<PuzzleMissionEvaluator>();
|
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
|
#endregion
|
||||||
|
|
||||||
builder.Services.AddTransient<ShadowverseTranslationMiddleware>();
|
builder.Services.AddTransient<ShadowverseTranslationMiddleware>();
|
||||||
|
|||||||
62
SVSim.EmulatedEntrypoint/Services/DeckCodeService.cs
Normal file
62
SVSim.EmulatedEntrypoint/Services/DeckCodeService.cs
Normal 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}";
|
||||||
|
}
|
||||||
17
SVSim.EmulatedEntrypoint/Services/IDeckCodeService.cs
Normal file
17
SVSim.EmulatedEntrypoint/Services/IDeckCodeService.cs
Normal 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);
|
||||||
|
}
|
||||||
109
SVSim.UnitTests/Controllers/DeckBuilderControllerTests.cs
Normal file
109
SVSim.UnitTests/Controllers/DeckBuilderControllerTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
SVSim.UnitTests/Services/DeckCodeServiceTests.cs
Normal file
56
SVSim.UnitTests/Services/DeckCodeServiceTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user