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:
@@ -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 "<unknown>"
|
||||
/// 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
|
||||
|
||||
@@ -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).");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user