feat(envelope): push required_res_ver from ResourceConfig on game_start
A wiped/fresh client (NukeIdentityOnStartup, new install, or any path that clears PlayerPrefs) defaults its stored RES_VER to "00000000" per Cute/SavedataManager.GetResourceVersion. The client builds the Akamai manifest URL as dl/Manifest/<RES_VER>/<lang>/<Platform>/, and Akamai 404s the "00000000" path -> Toolbox.AssetManager.InitializeManifest fails -> the title screen shows "Connection Error / Reconnect" before any tutorial UI loads. Fix: - New ResourceConfig [ConfigSection] in SVSim.Database — single field RequiredResVer defaulting to "4670rPsPMVlRTd2" (the value prod returned in data_dumps/traffic_prod_tutorial.ndjson and was still returning at 2026-05-28 21:00 UTC). Lives in GameConfigs so it can be tuned via DB / appsettings without code edits. - ShadowverseTranslationMiddleware injects IGameConfigService and emits required_res_ver in data_headers ONLY on /check/game_start responses. NetworkTask.Parse opens a "new data is available" popup whenever required_res_ver is present and the URL is anything other than GameStartCheck (NetworkTask.cs:128-138); the suppression on game_start is what lets us silently bump PlayerPrefs["RES_VER"] before ResourceDownloader runs. - DataHeaders gains a nullable RequiredResVer field. DataWrapper.DataHeaders is now Dictionary<string, object?> instead of the typed DataHeaders POCO directly — the construction site stays type-safe (the middleware builds the typed POCO, then projects through the same STJ + ConvertJsonTreeToPlainObject pipeline that DataWrapper.Data uses) so null-valued optional fields are absent from the wire instead of being written as "key":null. Without this, MessagePack's ContractlessStandardResolver walked the typed properties and wrote required_res_ver=null on every non-game_start response, tripping the popup on every boot. - GameConfigurationJsonbTests updated to expect the 9th config section. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
37
SVSim.Database/Models/Config/ResourceConfig.cs
Normal file
37
SVSim.Database/Models/Config/ResourceConfig.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
namespace SVSim.Database.Models.Config;
|
||||
|
||||
/// <summary>
|
||||
/// Asset-delivery tunables: where the client looks for the resource CDN (Akamai by default;
|
||||
/// <c>Wizard/SetUp.cs:48</c> hardcodes <c>shadowverse.akamaized.net/</c>) and what manifest
|
||||
/// version to ask for. Currently a single field, will grow as we self-host content.
|
||||
/// </summary>
|
||||
[ConfigSection("ResourceConfig")]
|
||||
public class ResourceConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Pushed to the client as <c>data_headers.required_res_ver</c>. The client writes it to
|
||||
/// <c>PlayerPrefs["RES_VER"]</c> and uses it as the version path component for asset
|
||||
/// manifest lookups: <c>https://<cdn>/dl/Manifest/<RES_VER>/<lang>/<Platform>/</c>.
|
||||
/// <para>
|
||||
/// Default value is the prod-captured version from <c>data_dumps/traffic_prod_tutorial.ndjson</c>
|
||||
/// (2026-05-28) — i.e., a path Akamai actually serves. When this rotates (or Akamai sunsets
|
||||
/// ahead of June 2026), update via DB <c>GameConfigs</c> row, appsettings.json, or this
|
||||
/// shipped default; no code change needed.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When the client has no cached <c>RES_VER</c> (e.g., a wiped/fresh install via
|
||||
/// <c>NukeIdentityOnStartup</c>), it defaults to <c>"00000000"</c>, which Akamai 404s. The
|
||||
/// fetch failure surfaces as "Connection Error / Reconnect" before any tutorial UI loads,
|
||||
/// so emitting a valid value here is required for fresh-account boot.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public string RequiredResVer { get; set; } = "4670rPsPMVlRTd2";
|
||||
|
||||
/// <summary>
|
||||
/// Inline-default tier for <see cref="IGameConfigService"/>. Mirrors property initialisers
|
||||
/// — kept as a separate factory because the framework requires every [ConfigSection] POCO to
|
||||
/// expose one (see <c>feedback_config_defaults</c> memory for the collection-defaults rule
|
||||
/// that motivated the convention).
|
||||
/// </summary>
|
||||
public static ResourceConfig ShippedDefaults() => new();
|
||||
}
|
||||
@@ -9,6 +9,8 @@ using Microsoft.AspNetCore.Mvc.Infrastructure;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Models.Config;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.EmulatedEntrypoint.Constants;
|
||||
using SVSim.EmulatedEntrypoint.Extensions;
|
||||
using SVSim.EmulatedEntrypoint.Infrastructure;
|
||||
@@ -26,6 +28,7 @@ public class ShadowverseTranslationMiddleware : IMiddleware
|
||||
{
|
||||
private readonly IActionDescriptorCollectionProvider _actionDescriptorCollectionProvider;
|
||||
private readonly ShadowverseSessionService _sessionService;
|
||||
private readonly IGameConfigService _gameConfig;
|
||||
private readonly ILogger<ShadowverseTranslationMiddleware> _logger;
|
||||
|
||||
// Serialization policy MUST match what AddJsonOptions configured on the controllers, or the
|
||||
@@ -41,10 +44,12 @@ public class ShadowverseTranslationMiddleware : IMiddleware
|
||||
public ShadowverseTranslationMiddleware(
|
||||
IActionDescriptorCollectionProvider actionDescriptorCollectionProvider,
|
||||
ShadowverseSessionService sessionService,
|
||||
IGameConfigService gameConfig,
|
||||
ILogger<ShadowverseTranslationMiddleware> logger)
|
||||
{
|
||||
_actionDescriptorCollectionProvider = actionDescriptorCollectionProvider;
|
||||
_sessionService = sessionService;
|
||||
_gameConfig = gameConfig;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -173,42 +178,73 @@ public class ShadowverseTranslationMiddleware : IMiddleware
|
||||
? null
|
||||
: ConvertJsonTreeToPlainObject(JToken.Parse(responseJson));
|
||||
|
||||
// Build the headers as a strongly-typed POCO so this construction site stays type-safe
|
||||
// (the alternative — a Dictionary<string, object> with literal-string keys here — is the
|
||||
// anti-pattern documented in the feedback_no_lazy_response_dicts memory).
|
||||
DataHeaders typedHeaders = new DataHeaders
|
||||
{
|
||||
Servertime = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
|
||||
// SID intentionally empty. See docs/api-spec/common/envelope.md §"SID
|
||||
// rotation" — the client's SessionId is a hash-on-read property, so echoing
|
||||
// the request's SID poisons its backing field and the next request hashes
|
||||
// the hash, missing our SID→UDID dict and crashing decryption. To rotate
|
||||
// sessions in the future, use the "stable-prefix + counter" pattern from
|
||||
// that doc (Option B), and pre-hash the rotated value to index the map by
|
||||
// what the client will actually send back on the next request.
|
||||
Sid = "",
|
||||
// Pushed ONLY on /check/game_start. NetworkTask.Parse opens the
|
||||
// "new data is available" popup whenever required_res_ver is present in
|
||||
// data_headers AND the URL isn't GameStartCheck (NetworkTask.cs:128-138 — the
|
||||
// popup is unconditionally skipped on game_start). Emitting on game_start
|
||||
// silently bumps PlayerPrefs["RES_VER"] before ResourceDownloader runs;
|
||||
// emitting anywhere else would surface a spurious "new data" dialog on every
|
||||
// boot for any client whose cached RES_VER trails the server's current value.
|
||||
RequiredResVer = path.Equals("/check/game_start", StringComparison.OrdinalIgnoreCase)
|
||||
? _gameConfig.Get<ResourceConfig>().RequiredResVer
|
||||
: null,
|
||||
// TODO error handling
|
||||
ResultCode = 1,
|
||||
// Anonymous endpoints (e.g. /check/special_title with [AllowAnonymous]) reach this
|
||||
// middleware without an authenticated viewer — the auth handler either declined or
|
||||
// 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 = 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 = skipEncryption ? "" : (mappedUdid?.ToString() ?? "")
|
||||
};
|
||||
|
||||
// Route the typed headers through the same STJ→JToken→dict pipeline that the controller
|
||||
// response (Data) goes through. STJ honours the global WhenWritingNull policy, so null
|
||||
// optional fields are absent from the JSON; ConvertJsonTreeToPlainObject preserves
|
||||
// "absent vs null" all the way to msgpack. Without this, MessagePack's contractless
|
||||
// resolver would walk the typed properties and emit "key":null for every nullable
|
||||
// field — RequiredResVer being the load-bearing case (a spurious null fires the
|
||||
// "new data available" popup via NetworkTask.isResourceVersionUp on every non-
|
||||
// game_start endpoint).
|
||||
string headersJson = JsonSerializer.Serialize(typedHeaders, ControllerJsonOptions);
|
||||
Dictionary<string, object?> headersDict =
|
||||
(ConvertJsonTreeToPlainObject(JToken.Parse(headersJson)) as Dictionary<string, object?>)
|
||||
?? throw new InvalidOperationException(
|
||||
"DataHeaders JSON projection didn't yield a JSON object — this should be unreachable: " +
|
||||
"DataHeaders is a typed POCO that always serializes to a single JSON object root.");
|
||||
|
||||
// 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,
|
||||
DataHeaders = new DataHeaders
|
||||
{
|
||||
Servertime = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
|
||||
// SID intentionally empty. See docs/api-spec/common/envelope.md §"SID
|
||||
// rotation" — the client's SessionId is a hash-on-read property, so echoing
|
||||
// the request's SID poisons its backing field and the next request hashes
|
||||
// the hash, missing our SID→UDID dict and crashing decryption. To rotate
|
||||
// sessions in the future, use the "stable-prefix + counter" pattern from
|
||||
// that doc (Option B), and pre-hash the rotated value to index the map by
|
||||
// what the client will actually send back on the next request.
|
||||
Sid = "",
|
||||
// TODO error handling
|
||||
ResultCode = 1,
|
||||
// Anonymous endpoints (e.g. /check/special_title with [AllowAnonymous]) reach this
|
||||
// middleware without an authenticated viewer — the auth handler either declined or
|
||||
// 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 = 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 = skipEncryption ? "" : (mappedUdid?.ToString() ?? "")
|
||||
}
|
||||
DataHeaders = headersDict
|
||||
};
|
||||
|
||||
// Convert the response into a messagepack, encrypt it. ContractlessStandardResolver
|
||||
// walks the DataWrapper's typed properties (DataHeaders) AND the boxed object/list/
|
||||
// primitive tree under Data — emitting only the keys present in the dictionary.
|
||||
// walks the boxed object/list/primitive tree under both DataHeaders and Data —
|
||||
// emitting only the keys present in each dictionary. Null-valued optional fields are
|
||||
// already stripped upstream by the STJ + ConvertJsonTreeToPlainObject pipeline.
|
||||
var msgPackOptions = MessagePackSerializerOptions.Standard
|
||||
.WithResolver(ContractlessStandardResolver.Instance);
|
||||
// Both branches base64-wrap the response body — the client's NetworkManager.Connect
|
||||
|
||||
@@ -32,4 +32,23 @@ public class DataHeaders
|
||||
[JsonPropertyName("udid")]
|
||||
[Key("udid")]
|
||||
public string Udid { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Tells the client the required version path component for asset manifests on the
|
||||
/// resource server (Akamai CDN, hardcoded to <c>shadowverse.akamaized.net/</c> in
|
||||
/// <c>Wizard/SetUp.cs:48</c>). <c>NetworkTask.setResourceVersion</c> writes the value
|
||||
/// to <c>PlayerPrefs["RES_VER"]</c>; the manifest URL becomes
|
||||
/// <c>dl/Manifest/<RES_VER>/<lang>/<Platform>/</c>. When the client
|
||||
/// has no cached <c>RES_VER</c> (e.g., after <c>NukeIdentityOnStartup</c> wipes
|
||||
/// PlayerPrefs), it defaults to <c>"00000000"</c>, which Akamai doesn't serve — the
|
||||
/// manifest fetch 404s and the client shows "Connection Error / Reconnect" before
|
||||
/// the tutorial UI ever appears.
|
||||
/// <para>
|
||||
/// Nullable to keep it off the wire on responses that don't need it (the global
|
||||
/// <c>WhenWritingNull</c> policy in Program.cs handles the omission).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[JsonPropertyName("required_res_ver")]
|
||||
[Key("required_res_ver")]
|
||||
public string? RequiredResVer { get; set; }
|
||||
}
|
||||
|
||||
@@ -10,11 +10,20 @@ namespace SVSim.EmulatedEntrypoint.Models.Dtos.Internal;
|
||||
public class DataWrapper
|
||||
{
|
||||
/// <summary>
|
||||
/// Additional data about the request, response and user.
|
||||
/// Wire-shape projection of the response envelope headers. The middleware builds a
|
||||
/// strongly-typed <see cref="DataHeaders"/> POCO and runs it through the same STJ +
|
||||
/// <c>ConvertJsonTreeToPlainObject</c> pipeline that the controller's response goes
|
||||
/// through, yielding this dict with absent keys for null-valued optional fields.
|
||||
/// Typed as <see cref="Dictionary{TKey,TValue}"/> (not <see cref="object"/>) because
|
||||
/// the projected shape is fully known — only the per-key value type varies. Direct
|
||||
/// assignment of the typed POCO would let MessagePack's contractless resolver emit
|
||||
/// <c>"key":null</c> for nullables, which the client treats as "key present" via
|
||||
/// <c>Keys.Contains</c> (see <c>NetworkTask.isResourceVersionUp</c> for the
|
||||
/// load-bearing case).
|
||||
/// </summary>
|
||||
[JsonPropertyName("data_headers")]
|
||||
[Key("data_headers")]
|
||||
public DataHeaders DataHeaders { get; set; } = new DataHeaders();
|
||||
[Key("data_headers")]
|
||||
public Dictionary<string, object?> DataHeaders { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// The response data from the endpoint.
|
||||
|
||||
@@ -25,14 +25,19 @@ public class GameConfigurationJsonbTests
|
||||
var rows = await db.GameConfigs.AsNoTracking().ToListAsync();
|
||||
var byName = rows.ToDictionary(r => r.SectionName);
|
||||
|
||||
// One row per [ConfigSection]-marked POCO (8 sections today: Player, DefaultGrants,
|
||||
// DefaultLoadout, Challenge, Rotation, PackRates, MyRotationSchedule, Story).
|
||||
// One row per [ConfigSection]-marked POCO (9 sections today: Player, DefaultGrants,
|
||||
// DefaultLoadout, Challenge, Rotation, PackRates, MyRotationSchedule, Story, ResourceConfig).
|
||||
Assert.That(byName.Keys, Is.EquivalentTo(new[]
|
||||
{
|
||||
"Player", "DefaultGrants", "DefaultLoadout", "Challenge", "Rotation", "PackRates",
|
||||
"MyRotationSchedule", "Story",
|
||||
"MyRotationSchedule", "Story", "ResourceConfig",
|
||||
}));
|
||||
|
||||
var resources = JsonSerializer.Deserialize<ResourceConfig>(byName["ResourceConfig"].ValueJson)!;
|
||||
Assert.That(resources.RequiredResVer, Is.EqualTo("4670rPsPMVlRTd2"),
|
||||
"ShippedDefaults RES_VER is the prod-captured (2026-05-28) Akamai manifest path " +
|
||||
"— required by the client to load the asset manifest after a wiped/fresh install.");
|
||||
|
||||
var mrSchedule = JsonSerializer.Deserialize<MyRotationScheduleConfig>(byName["MyRotationSchedule"].ValueJson)!;
|
||||
Assert.That(mrSchedule.FreeBattle.Begin, Is.EqualTo(new DateTime(2024, 5, 1, 20, 0, 0, DateTimeKind.Utc)),
|
||||
"ShippedDefaults reproduces the 2026-05-23 prod capture so a fresh install ships with Custom Rotation enabled");
|
||||
|
||||
Reference in New Issue
Block a user