feat(load-index): emit data.card_master_hash on mismatch (tier-1 freshness gate)
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user