feat(load-index): emit data.card_master_hash on mismatch (tier-1 freshness gate)

This commit is contained in:
gamer147
2026-06-12 12:27:40 -04:00
parent 92a21e7a7a
commit b4a279ef0c
3 changed files with 111 additions and 2 deletions

View File

@@ -48,11 +48,13 @@ public class LoadController : SVSimController
private readonly IViewerMissionStateService _missionState; private readonly IViewerMissionStateService _missionState;
private readonly SVSimDbContext _db; private readonly SVSimDbContext _db;
private readonly IInventoryService _inv; private readonly IInventoryService _inv;
private readonly ICardMasterPayloadProvider _cardMaster;
public LoadController(IViewerRepository viewerRepository, IGlobalsRepository globalsRepository, public LoadController(IViewerRepository viewerRepository, IGlobalsRepository globalsRepository,
IGameConfigService config, IGameConfigService config,
IBattlePassService battlePass, IViewerMissionStateService missionState, IBattlePassService battlePass, IViewerMissionStateService missionState,
SVSimDbContext db, IInventoryService inv) SVSimDbContext db, IInventoryService inv,
ICardMasterPayloadProvider cardMaster)
{ {
_viewerRepository = viewerRepository; _viewerRepository = viewerRepository;
_globalsRepository = globalsRepository; _globalsRepository = globalsRepository;
@@ -61,6 +63,7 @@ public class LoadController : SVSimController
_missionState = missionState; _missionState = missionState;
_db = db; _db = db;
_inv = inv; _inv = inv;
_cardMaster = cardMaster;
} }
[HttpPost("index")] [HttpPost("index")]
@@ -163,7 +166,7 @@ public class LoadController : SVSimController
var deviceHeader = Request.Headers["DEVICE"].FirstOrDefault(); var deviceHeader = Request.Headers["DEVICE"].FirstOrDefault();
int deviceType = int.TryParse(deviceHeader, out int parsed) ? parsed : 0; int deviceType = int.TryParse(deviceHeader, out int parsed) ? parsed : 0;
return new IndexResponse var response = new IndexResponse
{ {
UserTutorial = new UserTutorial { TutorialStep = viewer.MissionData.TutorialState }, UserTutorial = new UserTutorial { TutorialStep = viewer.MissionData.TutorialState },
UserInfo = new UserInfo(deviceType, viewer), UserInfo = new UserInfo(deviceType, viewer),
@@ -259,6 +262,21 @@ public class LoadController : SVSimController
DeckFormat = Format.Rotation, DeckFormat = Format.Rotation,
CardSetIdForResourceDlView = rotation.CardSetIdForResourceDlView, 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<CardMasterConfig>();
if (cardMasterCfg.EnableServing &&
!string.Equals(request.CardMasterHash, cardMasterCfg.CurrentHash, StringComparison.Ordinal))
{
response.CardMasterHash = cardMasterCfg.CurrentHash;
}
}
return response;
} }
/// <summary> /// <summary>

View File

@@ -42,6 +42,23 @@ public class IndexResponse
[Key("deck_format")] [Key("deck_format")]
public Format DeckFormat { get; set; } = Format.Rotation; public Format DeckFormat { get; set; } = Format.Rotation;
/// <summary>
/// Freshness trigger for the card-master refresh flow (Wizard/CardMaster.cs:18-30).
/// Nullable + global <c>WhenWritingNull</c> means absence on the wire when the request
/// already matches <c>CardMasterConfig.CurrentHash</c>. Presence (any value) tells the
/// client to call <c>POST /immutable_data/card_master</c> with this echoed back; the
/// client treats the string as opaque.
/// <para>
/// Lives on the inner <c>data</c> payload, NOT <c>data_headers</c> — verified by
/// <c>LoadDetail.cs:414</c> constructing <c>new CardMaster.UpdateInfo(jsonData)</c>
/// from the inner data, and the 2026-06-03 capture at
/// <c>data_dumps/captures/traffic_prod_allstars_freepack.ndjson</c>.
/// </para>
/// </summary>
[JsonPropertyName("card_master_hash")]
[Key("card_master_hash")]
public string? CardMasterHash { get; set; }
#endregion #endregion
#region Basic User Data #region Basic User Data

View File

@@ -0,0 +1,74 @@
using System.Net;
using System.Text;
using System.Text.Json;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Controllers;
/// <summary>
/// Covers the response side of card-master freshness: server emits inner
/// <c>data.card_master_hash</c> on <c>/load/index</c> only when the request's hash differs
/// from <c>CardMasterConfig.CurrentHash</c>. Presence-only client check + emit-every-time
/// would force a 1.27 MB redownload on every boot.
/// </summary>
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));
}
}