From b4a279ef0c8a590be82743f646aa077555670873 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Fri, 12 Jun 2026 12:27:40 -0400 Subject: [PATCH] feat(load-index): emit data.card_master_hash on mismatch (tier-1 freshness gate) --- .../Controllers/LoadController.cs | 22 +++++- .../Models/Dtos/Responses/IndexResponse.cs | 17 +++++ .../LoadControllerCardMasterHashTests.cs | 74 +++++++++++++++++++ 3 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 SVSim.UnitTests/Controllers/LoadControllerCardMasterHashTests.cs diff --git a/SVSim.EmulatedEntrypoint/Controllers/LoadController.cs b/SVSim.EmulatedEntrypoint/Controllers/LoadController.cs index 376a14d6..0b611d0c 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/LoadController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/LoadController.cs @@ -48,11 +48,13 @@ public class LoadController : SVSimController private readonly IViewerMissionStateService _missionState; private readonly SVSimDbContext _db; private readonly IInventoryService _inv; + private readonly ICardMasterPayloadProvider _cardMaster; public LoadController(IViewerRepository viewerRepository, IGlobalsRepository globalsRepository, IGameConfigService config, IBattlePassService battlePass, IViewerMissionStateService missionState, - SVSimDbContext db, IInventoryService inv) + SVSimDbContext db, IInventoryService inv, + ICardMasterPayloadProvider cardMaster) { _viewerRepository = viewerRepository; _globalsRepository = globalsRepository; @@ -61,6 +63,7 @@ public class LoadController : SVSimController _missionState = missionState; _db = db; _inv = inv; + _cardMaster = cardMaster; } [HttpPost("index")] @@ -163,7 +166,7 @@ public class LoadController : SVSimController var deviceHeader = Request.Headers["DEVICE"].FirstOrDefault(); int deviceType = int.TryParse(deviceHeader, out int parsed) ? parsed : 0; - return new IndexResponse + var response = new IndexResponse { UserTutorial = new UserTutorial { TutorialStep = viewer.MissionData.TutorialState }, UserInfo = new UserInfo(deviceType, viewer), @@ -259,6 +262,21 @@ public class LoadController : SVSimController DeckFormat = Format.Rotation, CardSetIdForResourceDlView = rotation.CardSetIdForResourceDlView, }; + + // Emit card_master_hash only when the client's local copy differs from the configured + // hash (presence-only client check — Wizard/CardMaster.cs:20). Emitting on every boot + // would force a 1.27 MB redownload every boot. Empty request hash = fresh client = mismatch. + if (_cardMaster.IsAvailable) + { + var cardMasterCfg = _config.Get(); + if (cardMasterCfg.EnableServing && + !string.Equals(request.CardMasterHash, cardMasterCfg.CurrentHash, StringComparison.Ordinal)) + { + response.CardMasterHash = cardMasterCfg.CurrentHash; + } + } + + return response; } /// diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/IndexResponse.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/IndexResponse.cs index 5d2e57c2..e7db9c4e 100644 --- a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/IndexResponse.cs +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/IndexResponse.cs @@ -42,6 +42,23 @@ public class IndexResponse [Key("deck_format")] public Format DeckFormat { get; set; } = Format.Rotation; + /// + /// Freshness trigger for the card-master refresh flow (Wizard/CardMaster.cs:18-30). + /// Nullable + global WhenWritingNull means absence on the wire when the request + /// already matches CardMasterConfig.CurrentHash. Presence (any value) tells the + /// client to call POST /immutable_data/card_master with this echoed back; the + /// client treats the string as opaque. + /// + /// Lives on the inner data payload, NOT data_headers — verified by + /// LoadDetail.cs:414 constructing new CardMaster.UpdateInfo(jsonData) + /// from the inner data, and the 2026-06-03 capture at + /// data_dumps/captures/traffic_prod_allstars_freepack.ndjson. + /// + /// + [JsonPropertyName("card_master_hash")] + [Key("card_master_hash")] + public string? CardMasterHash { get; set; } + #endregion #region Basic User Data diff --git a/SVSim.UnitTests/Controllers/LoadControllerCardMasterHashTests.cs b/SVSim.UnitTests/Controllers/LoadControllerCardMasterHashTests.cs new file mode 100644 index 00000000..1b14df2e --- /dev/null +++ b/SVSim.UnitTests/Controllers/LoadControllerCardMasterHashTests.cs @@ -0,0 +1,74 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Controllers; + +/// +/// Covers the response side of card-master freshness: server emits inner +/// data.card_master_hash on /load/index only when the request's hash differs +/// from CardMasterConfig.CurrentHash. Presence-only client check + emit-every-time +/// would force a 1.27 MB redownload on every boot. +/// +public class LoadControllerCardMasterHashTests +{ + private const string PinnedHash = "94b5c44edc51ff76c0af8fcc894af12f979dd38c:1"; + + private static string IndexRequestJsonWithHash(string hash) => + $$"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","carrier":"steam","card_master_hash":"{{hash}}"}"""; + + [Test] + public async Task Index_omits_card_master_hash_when_request_matches_server_hash() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var client = factory.CreateAuthenticatedClient(viewerId); + + var response = await client.PostAsync("/load/index", + new StringContent(IndexRequestJsonWithHash(PinnedHash), Encoding.UTF8, "application/json")); + + var body = await response.Content.ReadAsStringAsync(); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body); + + using var doc = JsonDocument.Parse(body); + Assert.That(doc.RootElement.TryGetProperty("card_master_hash", out _), Is.False, + "Expected card_master_hash OMITTED when request matches server. Body: " + body); + } + + [Test] + public async Task Index_emits_card_master_hash_when_request_differs() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var client = factory.CreateAuthenticatedClient(viewerId); + + var response = await client.PostAsync("/load/index", + new StringContent(IndexRequestJsonWithHash("oldhash:1"), Encoding.UTF8, "application/json")); + + var body = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(body); + Assert.That(doc.RootElement.TryGetProperty("card_master_hash", out var hashEl), Is.True, + "Expected card_master_hash PRESENT when request differs. Body: " + body); + Assert.That(hashEl.GetString(), Is.EqualTo(PinnedHash)); + } + + [Test] + public async Task Index_emits_card_master_hash_when_request_hash_empty() + { + // Empty hash = fresh client with no cardmaster/card_master_1 on disk + // (CardMasterLocalFileUtility.GetCardMasterHash returns ""). Treat as mismatch. + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var client = factory.CreateAuthenticatedClient(viewerId); + + var response = await client.PostAsync("/load/index", + new StringContent(IndexRequestJsonWithHash(""), Encoding.UTF8, "application/json")); + + var body = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(body); + Assert.That(doc.RootElement.TryGetProperty("card_master_hash", out var hashEl), Is.True, + "Expected card_master_hash PRESENT for fresh client. Body: " + body); + Assert.That(hashEl.GetString(), Is.EqualTo(PinnedHash)); + } +}