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