From e7dac31d52c2363e7408590bfcd1ae61a1691f7d Mon Sep 17 00:00:00 2001 From: gamer147 Date: Mon, 1 Jun 2026 01:49:56 -0400 Subject: [PATCH] fix(check): emit rewrite_viewer_id when UDID and Steam viewers disagree Wipe-and-resignup left the client stuck with the blank V_new's id in Certification.ViewerId. /tool/signup is anonymous, so it can't see the Steam ticket and creates a fresh anonymous viewer keyed on the new UDID; the Steam handler on the next request resolves to V_old and serves its data, but no normal-response hook overwrites Certification.ViewerId. GameStart now compares the UDID-keyed viewer to the auth-resolved one and emits rewrite_viewer_id when they differ, which Cute/GameStartCheckTask writes back into Certification.ViewerId. Co-Authored-By: Claude Opus 4.7 --- .../Controllers/CheckController.cs | 20 +++++++ .../Controllers/CheckControllerTests.cs | 55 +++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/SVSim.EmulatedEntrypoint/Controllers/CheckController.cs b/SVSim.EmulatedEntrypoint/Controllers/CheckController.cs index af89b3a..5b6799c 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/CheckController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/CheckController.cs @@ -38,12 +38,32 @@ public class CheckController : SVSimController ?? throw new InvalidOperationException("Auth handler must set viewer in context."); Viewer fullViewer = await _viewerRepository.GetViewerWithSocials(viewer.Id) ?? viewer; + // Wipe-and-resignup reconciliation: /tool/signup is anonymous on the wire and can't see + // the Steam ticket, so a freshly-wiped client lands a blank V_new keyed on its new UDID + // while the Steam handler on this very request resolves to the original V_old. The client + // has already written V_new.Id into Certification.ViewerId from the signup response; left + // alone, it stays wrong forever (NormalTask.Parse never reads data_headers.viewer_id — + // only SignUpTask / GameStartCheckTask.rewrite_viewer_id / the social-chain tasks do). + // Detect the mismatch by re-looking-up the UDID-keyed viewer and emit rewrite_viewer_id + // when it disagrees with the auth-resolved one. + long? rewriteViewerId = null; + Guid? udid = HttpContext.GetUdid(); + if (udid is Guid u && u != Guid.Empty) + { + Viewer? udidViewer = await _viewerRepository.GetViewerByUdid(u); + if (udidViewer is not null && udidViewer.Id != fullViewer.Id) + { + rewriteViewerId = fullViewer.Id; + } + } + return new GameStartResponse { NowViewerId = fullViewer.Id, NowName = fullViewer.DisplayName, NowTutorialStep = fullViewer.MissionData.TutorialState.ToString(), IsSetTransitionPassword = true, + RewriteViewerId = rewriteViewerId, // Stub rank map until per-format ranks are persisted (prod observed: "1"/"2"/"4" // keys mapping to RankName_010 / RankName_017). Empty dict here may be safe but // we don't yet know which client paths read this — match prod stub. diff --git a/SVSim.UnitTests/Controllers/CheckControllerTests.cs b/SVSim.UnitTests/Controllers/CheckControllerTests.cs index ed83fda..3a23af0 100644 --- a/SVSim.UnitTests/Controllers/CheckControllerTests.cs +++ b/SVSim.UnitTests/Controllers/CheckControllerTests.cs @@ -1,6 +1,9 @@ using System.Net; using System.Text; using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Database.Repositories.Viewer; +using SVSim.EmulatedEntrypoint.Services; using SVSim.UnitTests.Infrastructure; namespace SVSim.UnitTests.Controllers; @@ -103,6 +106,58 @@ public class CheckControllerTests "account_delete_reservation_status must NOT be present — presence triggers client behavior."); } + [Test] + public async Task GameStart_emits_rewrite_viewer_id_when_udid_keyed_viewer_differs_from_authed_viewer() + { + // Reproduces the wipe-and-resignup scenario: the client wiped local prefs, hit + // /tool/signup with a fresh UDID (creating a blank anonymous viewer V_new), then + // hit /check/game_start carrying the same Steam ticket. The Steam handler resolves + // to V_old (the original viewer with the Steam link), but the client still thinks + // it is V_new from the signup response. rewrite_viewer_id is the documented client + // hook for correcting Certification.ViewerId — see Cute/GameStartCheckTask.cs:113. + using var factory = new SVSimTestFactory(); + long oldViewerId = await factory.SeedViewerAsync(); + + // V_new: blank-named anonymous viewer keyed by a fresh UDID, as RegisterAnonymousViewer + // would have produced inside /tool/signup. Resolved via the real repo so the row matches + // what production lays down. + Guid freshUdid = Guid.NewGuid(); + long newViewerId; + using (var scope = factory.Services.CreateScope()) + { + var repo = scope.ServiceProvider.GetRequiredService(); + var v = await repo.RegisterAnonymousViewer(freshUdid); + newViewerId = v.Id; + } + + // SID→UDID mapping for U_new, so HttpContext.GetUdid() resolves to it inside the + // controller (same setup MakeClientWithUdid in ToolControllerTests uses). + const string sid = "wipe-resignup-test-sid"; + using (var scope = factory.Services.CreateScope()) + { + var session = scope.ServiceProvider.GetRequiredService(); + session.StoreUdidForSessionId(sid, freshUdid); + } + + using var client = factory.CreateAuthenticatedClient(oldViewerId); + client.DefaultRequestHeaders.Add("SID", sid); + + var response = await client.PostAsync("/check/game_start", + new StringContent(GameStartRequestJson, 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); + var root = doc.RootElement; + + Assert.That(root.TryGetProperty("rewrite_viewer_id", out var rewrite), Is.True, + $"rewrite_viewer_id must be present when UDID-keyed viewer ({newViewerId}) differs from auth-resolved viewer ({oldViewerId}). " + + "Without it the client stays stuck on the wrong Certification.ViewerId after a wipe-and-resignup. Body: " + body); + Assert.That(rewrite.GetInt64(), Is.EqualTo(oldViewerId), + "rewrite_viewer_id must point to the auth-resolved (Steam-linked) viewer, not the UDID-keyed anonymous one."); + } + [Test] public async Task GameStart_with_no_viewer_returns_401() {