diff --git a/SVSim.Database/Models/Config/ResourceConfig.cs b/SVSim.Database/Models/Config/ResourceConfig.cs new file mode 100644 index 0000000..6a5373e --- /dev/null +++ b/SVSim.Database/Models/Config/ResourceConfig.cs @@ -0,0 +1,37 @@ +namespace SVSim.Database.Models.Config; + +/// +/// Asset-delivery tunables: where the client looks for the resource CDN (Akamai by default; +/// Wizard/SetUp.cs:48 hardcodes shadowverse.akamaized.net/) and what manifest +/// version to ask for. Currently a single field, will grow as we self-host content. +/// +[ConfigSection("ResourceConfig")] +public class ResourceConfig +{ + /// + /// Pushed to the client as data_headers.required_res_ver. The client writes it to + /// PlayerPrefs["RES_VER"] and uses it as the version path component for asset + /// manifest lookups: https://<cdn>/dl/Manifest/<RES_VER>/<lang>/<Platform>/. + /// + /// Default value is the prod-captured version from data_dumps/traffic_prod_tutorial.ndjson + /// (2026-05-28) — i.e., a path Akamai actually serves. When this rotates (or Akamai sunsets + /// ahead of June 2026), update via DB GameConfigs row, appsettings.json, or this + /// shipped default; no code change needed. + /// + /// + /// When the client has no cached RES_VER (e.g., a wiped/fresh install via + /// NukeIdentityOnStartup), it defaults to "00000000", 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. + /// + /// + public string RequiredResVer { get; set; } = "4670rPsPMVlRTd2"; + + /// + /// Inline-default tier for . Mirrors property initialisers + /// — kept as a separate factory because the framework requires every [ConfigSection] POCO to + /// expose one (see feedback_config_defaults memory for the collection-defaults rule + /// that motivated the convention). + /// + public static ResourceConfig ShippedDefaults() => new(); +} diff --git a/SVSim.EmulatedEntrypoint/Middlewares/ShadowverseTranslationMiddleware.cs b/SVSim.EmulatedEntrypoint/Middlewares/ShadowverseTranslationMiddleware.cs index cd32b34..8bcc3da 100644 --- a/SVSim.EmulatedEntrypoint/Middlewares/ShadowverseTranslationMiddleware.cs +++ b/SVSim.EmulatedEntrypoint/Middlewares/ShadowverseTranslationMiddleware.cs @@ -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 _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 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 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().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 headersDict = + (ConvertJsonTreeToPlainObject(JToken.Parse(headersJson)) as Dictionary) + ?? 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 diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Internal/DataHeaders.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Internal/DataHeaders.cs index e39bd61..208d32a 100644 --- a/SVSim.EmulatedEntrypoint/Models/Dtos/Internal/DataHeaders.cs +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Internal/DataHeaders.cs @@ -32,4 +32,23 @@ public class DataHeaders [JsonPropertyName("udid")] [Key("udid")] public string Udid { get; set; } = ""; + + /// + /// Tells the client the required version path component for asset manifests on the + /// resource server (Akamai CDN, hardcoded to shadowverse.akamaized.net/ in + /// Wizard/SetUp.cs:48). NetworkTask.setResourceVersion writes the value + /// to PlayerPrefs["RES_VER"]; the manifest URL becomes + /// dl/Manifest/<RES_VER>/<lang>/<Platform>/. When the client + /// has no cached RES_VER (e.g., after NukeIdentityOnStartup wipes + /// PlayerPrefs), it defaults to "00000000", which Akamai doesn't serve — the + /// manifest fetch 404s and the client shows "Connection Error / Reconnect" before + /// the tutorial UI ever appears. + /// + /// Nullable to keep it off the wire on responses that don't need it (the global + /// WhenWritingNull policy in Program.cs handles the omission). + /// + /// + [JsonPropertyName("required_res_ver")] + [Key("required_res_ver")] + public string? RequiredResVer { get; set; } } diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Internal/DataWrapper.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Internal/DataWrapper.cs index 150668c..a2d0c40 100644 --- a/SVSim.EmulatedEntrypoint/Models/Dtos/Internal/DataWrapper.cs +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Internal/DataWrapper.cs @@ -10,11 +10,20 @@ namespace SVSim.EmulatedEntrypoint.Models.Dtos.Internal; public class DataWrapper { /// - /// Additional data about the request, response and user. + /// Wire-shape projection of the response envelope headers. The middleware builds a + /// strongly-typed POCO and runs it through the same STJ + + /// ConvertJsonTreeToPlainObject pipeline that the controller's response goes + /// through, yielding this dict with absent keys for null-valued optional fields. + /// Typed as (not ) 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 + /// "key":null for nullables, which the client treats as "key present" via + /// Keys.Contains (see NetworkTask.isResourceVersionUp for the + /// load-bearing case). /// [JsonPropertyName("data_headers")] - [Key("data_headers")] - public DataHeaders DataHeaders { get; set; } = new DataHeaders(); + [Key("data_headers")] + public Dictionary DataHeaders { get; set; } = new(); /// /// The response data from the endpoint. diff --git a/SVSim.UnitTests/Models/GameConfigurationJsonbTests.cs b/SVSim.UnitTests/Models/GameConfigurationJsonbTests.cs index c33040f..ea39563 100644 --- a/SVSim.UnitTests/Models/GameConfigurationJsonbTests.cs +++ b/SVSim.UnitTests/Models/GameConfigurationJsonbTests.cs @@ -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(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(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");