fix(viewer): fresh signup defaults match prod tutorial capture

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 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-05-28 18:02:52 -04:00
parent 6e6c8ee779
commit 1af0e03eeb
2 changed files with 64 additions and 13 deletions

View File

@@ -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<Models.Viewer>().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;
}
/// <summary>
/// Extracts the violated constraint name from a wrapped backend exception, when available.
/// Postgres surfaces this as <c>PostgresException.ConstraintName</c>. Returns "&lt;unknown&gt;"
/// for other backends or when the name can't be reflected out.
/// </summary>
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 "<unknown>";
}
/// <summary>
/// Returns true if the given <see cref="DbUpdateException"/> 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<EmblemEntry>().FindAsync(defaultEmblemId);
var defaultBg = await _dbContext.Set<MyPageBackgroundEntry>().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

View File

@@ -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).");
}
}