From 1af0e03eeb5af86e5fb5e0056ef8c6d63c3c43e3 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Thu, 28 May 2026 18:02:52 -0400 Subject: [PATCH] fix(viewer): fresh signup defaults match prod tutorial capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three corrections to BuildDefaultViewer + RegisterAnonymousViewer verified against data_dumps/traffic_prod_tutorial.ndjson: - TutorialState now 1 (TUTORIAL_STEP0), not 0 (PRE_TUTORIAL_STEP). Wizard.Title.NextSceneSwitcher routes step==1 to the Prologue scene; any other non-{31,41,100} step routes to AreaSelect at section 0, which has no chapter data and crashes the client with a LINQ Single() "Sequence contains no matching element" from AreaSelectUI.SelectChapter. Prod's first /check/game_start returns now_tutorial_step="1"; step 0 is a pre-existence state we never want to expose on the wire. - DisplayName " - " (literal space-dash-space), not "Player". Wizard.Title.UserNameInput.Start short-circuits with IsFinished=true on !string.IsNullOrEmpty(PlayerStaticData.UserName), silently skipping the name dialog AND the tutorial sub-step it drives (/tutorial/update_action #1 + /account/update_name). Prod uses " - " as the unset placeholder. - viewer.Info.SelectedEmblem/SelectedDegree assigned from the default emblem/degree the loadout grants. Without this, /load/index emits selected_emblem_id=0 and selected_degree_id=0 for a fresh viewer that owns those cosmetics — prod sends the real granted IDs. Also surfaces the actual Postgres constraint name in the unique- violation re-raise (ExtractConstraintName), instead of always saying "UDID" — the original message was misleading whenever the real constraint was on owned-collection rows. Co-Authored-By: Claude Opus 4.7 --- .../Repositories/Viewer/ViewerRepository.cs | 65 ++++++++++++++++--- .../ViewerRepositoryTutorialDefaultTests.cs | 12 ++-- 2 files changed, 64 insertions(+), 13 deletions(-) diff --git a/SVSim.Database/Repositories/Viewer/ViewerRepository.cs b/SVSim.Database/Repositories/Viewer/ViewerRepository.cs index 6132c4d..bfd802f 100644 --- a/SVSim.Database/Repositories/Viewer/ViewerRepository.cs +++ b/SVSim.Database/Repositories/Viewer/ViewerRepository.cs @@ -96,7 +96,12 @@ public class ViewerRepository : IViewerRepository if (udid == Guid.Empty) throw new InvalidOperationException("Cannot register viewer for empty UDID."); - var viewer = await BuildDefaultViewer("Player"); + // " - " (space-dash-space) matches the prod placeholder for a fresh-signup viewer + // before the user enters their name. Non-empty + non-placeholder values trip + // UserNameInput.Start's IsNullOrEmpty short-circuit, which silently skips the name + // dialog AND the /tutorial/update_action #1 + /account/update_name calls that + // accompany it — that whole sub-step of the tutorial then never runs. + var viewer = await BuildDefaultViewer(" - "); viewer.Udid = udid; _dbContext.Set().Add(viewer); try @@ -114,15 +119,41 @@ public class ViewerRepository : IViewerRepository // SqliteErrorCode 19 (SQLITE_CONSTRAINT). Matched by type-name to avoid pulling a // Sqlite package dep into SVSim.Database. _dbContext.Entry(viewer).State = EntityState.Detached; - var existing = await GetViewerByUdid(udid) - ?? throw new InvalidOperationException( - $"Got unique-violation on Udid={udid} insert but subsequent lookup found no row. " + - "This shouldn't happen — likely transaction isolation issue."); - return existing; + var existing = await GetViewerByUdid(udid); + if (existing is not null) return existing; + + // Lookup-by-UDID missed → the violation wasn't on the UDID index. Pull the constraint + // name out of the inner exception so the caller can see which constraint actually + // blocked the insert (Steam social uniqueness, owned-collection uniqueness, etc.). + string constraintName = ExtractConstraintName(ex); + throw new InvalidOperationException( + $"Got unique-violation on viewer insert for Udid={udid} but the UDID is not in the table. " + + $"The violated constraint was '{constraintName}'. " + + "Original exception preserved as InnerException.", + ex); } return viewer; } + /// + /// Extracts the violated constraint name from a wrapped backend exception, when available. + /// Postgres surfaces this as PostgresException.ConstraintName. Returns "<unknown>" + /// for other backends or when the name can't be reflected out. + /// + private static string ExtractConstraintName(DbUpdateException ex) + { + if (ex.InnerException is Npgsql.PostgresException pgEx && !string.IsNullOrEmpty(pgEx.ConstraintName)) + { + return pgEx.ConstraintName; + } + // SQLite doesn't expose a constraint name in a structured field — fall back to the message. + if (ex.InnerException is { } inner && inner.GetType().FullName == "Microsoft.Data.Sqlite.SqliteException") + { + return inner.Message; + } + return ""; + } + /// /// Returns true if the given wraps a backend-level unique- /// constraint violation. Postgres → SqlState "23505"; SQLite → SqliteErrorCode 19. @@ -180,7 +211,15 @@ public class ViewerRepository : IViewerRepository viewer.Currency.Crystals = grants.Crystals; viewer.Currency.Rupees = grants.Rupees; viewer.Currency.RedEther = grants.Ether; - viewer.MissionData.TutorialState = 0; // PRE_TUTORIAL_STEP — fresh signups go through the tutorial flow + // TUTORIAL_STEP0 (= 1) — "fresh signup, ready to play the prologue", confirmed by the + // prod capture in data_dumps/traffic_prod_tutorial.ndjson (game_start returned + // now_tutorial_step="1"). Step 0 is a pre-existence state we never want to expose + // on the wire: Wizard.Title.NextSceneSwitcher routes step==1 → Prologue scene and + // every other non-{31,41,100} step → AreaSelect at section 0, which has no chapter + // data (the prologue's chapters are baked into Wizard/Prologue.cs, not served) and + // crashes AreaSelectUI.SelectChapter with a "Sequence contains no matching element" + // LINQ Single() failure. + viewer.MissionData.TutorialState = 1; // Load classes WITH their LeaderSkins — DefaultLeaderSkin iterates the nav collection // and would otherwise be null (audit §6 #3 latent NRE — this is the one). @@ -209,8 +248,16 @@ public class ViewerRepository : IViewerRepository var defaultEmblem = await _dbContext.Set().FindAsync(defaultEmblemId); var defaultBg = await _dbContext.Set().FindAsync(defaultBgId); if (defaultSleeve is not null) viewer.Sleeves.Add(defaultSleeve); - if (defaultDegree is not null) viewer.Degrees.Add(defaultDegree); - if (defaultEmblem is not null) viewer.Emblems.Add(defaultEmblem); + if (defaultDegree is not null) + { + viewer.Degrees.Add(defaultDegree); + viewer.Info.SelectedDegree = defaultDegree; + } + if (defaultEmblem is not null) + { + viewer.Emblems.Add(defaultEmblem); + viewer.Info.SelectedEmblem = defaultEmblem; + } if (defaultBg is not null) viewer.MyPageBackgrounds.Add(defaultBg); // Grant one of each class's default leader skin. Filter out the synthetic placeholders diff --git a/SVSim.UnitTests/Repositories/ViewerRepositoryTutorialDefaultTests.cs b/SVSim.UnitTests/Repositories/ViewerRepositoryTutorialDefaultTests.cs index 9175903..d88fbc8 100644 --- a/SVSim.UnitTests/Repositories/ViewerRepositoryTutorialDefaultTests.cs +++ b/SVSim.UnitTests/Repositories/ViewerRepositoryTutorialDefaultTests.cs @@ -9,7 +9,7 @@ namespace SVSim.UnitTests.Repositories; public class ViewerRepositoryTutorialDefaultTests { [Test] - public async Task RegisterAnonymousViewer_starts_at_tutorial_step_0() + public async Task RegisterAnonymousViewer_starts_at_tutorial_step_1() { using var factory = new SVSimTestFactory(); using var scope = factory.Services.CreateScope(); @@ -17,8 +17,12 @@ public class ViewerRepositoryTutorialDefaultTests var viewer = await repo.RegisterAnonymousViewer(Guid.NewGuid()); - Assert.That(viewer.MissionData.TutorialState, Is.EqualTo(0), - "Fresh signups must start at PRE_TUTORIAL_STEP=0 so the client triggers the tutorial flow. " + - "Tests that want a pre-completed tutorial should use SeedViewerAsync (which defaults to 100)."); + Assert.That(viewer.MissionData.TutorialState, Is.EqualTo(1), + "Fresh signups start at TUTORIAL_STEP0=1 (matches the prod capture in " + + "traffic_prod_tutorial.ndjson where game_start returned now_tutorial_step=\"1\"). " + + "Step 0 (PRE_TUTORIAL_STEP) is a pre-existence state — NextSceneSwitcher would " + + "route it to AreaSelect at section 0, which has no chapter data and crashes the " + + "client. Tests that want a pre-completed tutorial should use SeedViewerAsync " + + "(which defaults to 100)."); } }