Files
SVSimServer/SVSim.UnitTests/Repositories/ViewerRepositoryTests.cs
gamer147 2d9a6eea4b engine cleanup passes 4-7 + multi-instancing ambient rip
Squashes 146 commits from battle-engine-extraction. Net: 2,045 files changed,
+11,896 / -158,687 lines. Ships engine passes 4-7 (dead-code cull, view-layer
stub, receive-path shrink) plus the Phase-5 AsyncLocal ambient deletion that
turns concurrent battles into a type-system property rather than a scope
contract.

## What landed

**Passes 4-7 (chunks 1-34):** Extended the Phase-4 const-false collapse into a
cascading cull across the skill graph, view layer, and receive-path periphery.
Six mode flags (IsWatchBattle/IsReplayBattle/IsAdmin/IsAdminWatch/IsPuzzleQuest/
IsAINetwork) became `const false`, every guarded block deleted. Field*.cs
subclass ctors + BackGroundBase + ObjectChecker culled to no-ops. Mulligan
family reworked to take a mgr param through IMulliganMgr.InitMulligan.
Emotion/Recovery/Resource clusters null-stubbed. Prediction/OperationSimulator/
skill filters converted from static ambient reads to per-mgr reads via
SkillPrm.ownerCard.SelfBattlePlayer.BattleMgr / ins.BattleMgr / this.BattleMgr.

**Phase-5 ambient rip (chunks 35-47):** Deleted BattleAmbient / BattleAmbient-
Context / TestBattleScope in full. Every per-battle mutable slot now lives on
the mgr instance itself:
  mgr.InstanceIsForecast / InstanceIsRandomDraw / InstanceRecoveryInfo /
  InstanceViewerId / InstanceNetworkAgent / GameMgr
BattleManagerBase.GetIns() returns null unconditionally; the residual static
flags + 3 façades (Certification.ViewerId, Data.BattleRecoveryInfo,
ToolboxGame.RealTimeNetworkAgent) are null-tolerant defaults kept for the
handful of engine-internal readers that still reference their types. Zero
BattleAmbient references anywhere in engine + node + tests.

Added pre-seeded GameMgr ctor overload threaded through the mgr chain
(BattleManagerBase → SingleBattleMgr / NetworkBattleManagerBase → NetworkStandard-
BattleMgr → HeadlessBattleMgr / HeadlessNetworkBattleMgr). Fixtures build a
GameMgr, seed it via HeadlessEngineEnv.SeedCharaIds/SeedNetUser, and pass it
to the mgr's ctor — no ambient reach.

Node side (SVSim.BattleNode/SessionBattleEngine): _ctx replaced with a plain
GameMgr field; 34 `using var _ambient = BattleAmbient.Enter(_ctx)` scope wraps
ripped from every accessor and mutator; EngineGlobalInit.WirePerSessionGameMgr
takes GameMgr as a param and runs from SessionBattleEngine.SetupInternal
BEFORE mgr construction.

Test side: TestBattleScope deleted; 18 fixture [SetUp]s migrated to
`HeadlessEngineEnv.EnsureProcessGlobals()`; MultiInstanceEngineTests rewritten
around per-mgr construction (GetIns() → null is the pinned invariant).

## Regression fixes

- **chunk-48** (MulliganCtrl): chunk-35's `= null` stubs on card lookups broke
  the live receive-driven Deal path (BattlePlayerBase.DrawCard NRE'd downstream
  of NetworkPlayerMulliganCtrl.StartMulliganVfx). Restored the three lookups
  via `_battlePlayer.BattleMgr.GetBattleCardIdx`. Engine tests were satisfied
  by the WireMulliganPhase seam; unit tests exposed the live-path gap.

## Ship state

- SVSim.BattleEngine.Tests: 56/56 pass, 2 skip
- SVSim.UnitTests: 1554/1554 pass (was 1523/31-fail before chunk 48)
- Solution build: 0 source warnings (40 pre-existing NU1902 MessagePack CVEs
  in SVSim.EmulatedEntrypoint, unrelated)
- Sequential PVP smoke: verified live (two back-to-back battles, no regression
  on cleanup/spinup)
- Concurrent PVP smoke: verified live

Adds tools/engine-port/ClosureAnalyzer/ — the Roslyn transitive-type-closure
analyzer needed to make future cascade cleanup safe (per feedback memory
"Engine cleanup needs closure tool" from the 2026-06-28 pass-3 failure).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-03 19:18:54 -04:00

259 lines
11 KiB
C#

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Repositories.Viewer;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Repositories;
/// <summary>
/// Direct tests against <see cref="ViewerRepository"/>. The owned-type lookup in
/// <see cref="ViewerRepository.GetViewerBySocialConnection"/> previously used
/// <c>_dbContext.Set&lt;SocialAccountConnection&gt;()</c> which EF couldn't translate (owned
/// types aren't queryable as a root). This test would have caught the regression.
/// </summary>
public class ViewerRepositoryTests
{
[Test]
public async Task GetViewerBySocialConnection_returns_viewer_when_steam_id_matches()
{
using var factory = new SVSimTestFactory();
const ulong steamId = 76_561_198_111_222_333UL;
long expectedViewerId = await factory.SeedViewerAsync(steamId: steamId, displayName: "Owner");
using var scope = factory.Services.CreateScope();
var repo = scope.ServiceProvider.GetRequiredService<IViewerRepository>();
Viewer? found = await repo.GetViewerBySocialConnection(SocialAccountType.Steam, steamId);
Assert.That(found, Is.Not.Null, "Expected to find the seeded viewer by Steam social connection.");
Assert.That(found!.Id, Is.EqualTo(expectedViewerId));
Assert.That(found.DisplayName, Is.EqualTo("Owner"));
}
[Test]
public async Task GetViewerBySocialConnection_returns_null_when_steam_id_does_not_match()
{
using var factory = new SVSimTestFactory();
await factory.SeedViewerAsync(steamId: 76_561_198_111_222_333UL);
using var scope = factory.Services.CreateScope();
var repo = scope.ServiceProvider.GetRequiredService<IViewerRepository>();
Viewer? found = await repo.GetViewerBySocialConnection(SocialAccountType.Steam, 76_561_198_999_999_999UL);
Assert.That(found, Is.Null);
}
[Test]
public async Task RegisterViewer_grants_default_leader_skins_to_classes()
{
// Guards the just-fixed nav-graph NRE — RegisterViewer iterates ClassEntry.LeaderSkins
// and needs the .Include to populate them. If the include is lost, this throws inside
// SeedViewerAsync rather than reaching the assertion.
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
Viewer viewer = await db.Viewers
.Include(v => v.Classes).ThenInclude(c => c.LeaderSkin)
.Include(v => v.LeaderSkins)
.FirstAsync(v => v.Id == viewerId);
Assert.That(viewer.Classes, Is.Not.Empty, "RegisterViewer should populate Classes from seed data.");
Assert.That(viewer.Classes.Select(c => c.LeaderSkin).All(s => s is not null), Is.True,
"Every class should have a LeaderSkin assigned (placeholder or real).");
Assert.That(viewer.LeaderSkins, Is.Not.Empty,
"Viewer should own at least one leader skin from class defaults.");
}
[Test]
public async Task RegisterAnonymousViewer_creates_viewer_with_udid_and_no_socials()
{
using var factory = new SVSimTestFactory();
var udid = Guid.NewGuid();
long viewerId;
using (var scope = factory.Services.CreateScope())
{
var repo = scope.ServiceProvider.GetRequiredService<IViewerRepository>();
var v = await repo.RegisterAnonymousViewer(udid);
viewerId = v.Id;
}
using var verifyScope = factory.Services.CreateScope();
var db = verifyScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var loaded = await db.Viewers
.Include(v => v.Classes)
.Include(v => v.SocialAccountConnections)
.FirstAsync(v => v.Id == viewerId);
Assert.That(loaded.Udid, Is.EqualTo(udid));
Assert.That(loaded.SocialAccountConnections, Is.Empty);
Assert.That(loaded.Classes, Is.Not.Empty,
"Default-loadout body should populate Classes (smoke-test the shared BuildDefaultViewer helper).");
}
[Test]
public async Task RegisterAnonymousViewer_concurrent_same_udid_returns_existing_not_duplicate()
{
using var factory = new SVSimTestFactory();
var udid = Guid.NewGuid();
long firstId;
using (var scope = factory.Services.CreateScope())
{
var repo = scope.ServiceProvider.GetRequiredService<IViewerRepository>();
firstId = (await repo.RegisterAnonymousViewer(udid)).Id;
}
// Second register with the same UDID — simulates the race losing thread after the unique
// index has caught the duplicate. We expect a clean re-fetch, NOT an exception or duplicate.
long secondId;
using (var scope = factory.Services.CreateScope())
{
var repo = scope.ServiceProvider.GetRequiredService<IViewerRepository>();
secondId = (await repo.RegisterAnonymousViewer(udid)).Id;
}
Assert.That(secondId, Is.EqualTo(firstId),
"Second register must return the existing row, not create a duplicate.");
using var verifyScope = factory.Services.CreateScope();
var db = verifyScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var count = await db.Viewers.CountAsync(v => v.Udid == udid);
Assert.That(count, Is.EqualTo(1));
}
[Test]
public async Task RegisterAnonymousViewer_with_empty_udid_throws()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var repo = scope.ServiceProvider.GetRequiredService<IViewerRepository>();
Assert.ThrowsAsync<InvalidOperationException>(async () =>
await repo.RegisterAnonymousViewer(Guid.Empty));
}
[Test]
public async Task RegisterAnonymousViewer_default_starts_at_tutorial_step_1()
{
using var factory = new SVSimTestFactory();
var udid = Guid.NewGuid();
using var scope = factory.Services.CreateScope();
var repo = scope.ServiceProvider.GetRequiredService<IViewerRepository>();
var viewer = await repo.RegisterAnonymousViewer(udid);
Assert.That(viewer.MissionData.TutorialState, Is.EqualTo(1),
"Fresh signups default to TUTORIAL_STEP0 (=1) so the client walks through the " +
"real tutorial. SkipTutorialConfig can toggle this to 100 (post-tutorial) for " +
"dev / two-client-smoke fast paths.");
}
[Test]
public async Task RegisterAnonymousViewer_with_SkipTutorial_enabled_starts_at_state_100()
{
using var factory = new SVSimTestFactory();
await factory.EnableSkipTutorialAsync();
var udid = Guid.NewGuid();
using var scope = factory.Services.CreateScope();
var repo = scope.ServiceProvider.GetRequiredService<IViewerRepository>();
var viewer = await repo.RegisterAnonymousViewer(udid);
Assert.That(viewer.MissionData.TutorialState, Is.EqualTo(100),
"When SkipTutorialConfig.Enabled is true, fresh signups land at the post-tutorial " +
"baseline (=100) — cuts tutorial-walk-through time in the two-client PVP smoke.");
}
[Test]
public async Task RegisterViewer_starts_at_post_tutorial_state()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var repo = scope.ServiceProvider.GetRequiredService<IViewerRepository>();
var viewer = await repo.RegisterViewer(
"Imported Viewer",
SVSim.Database.Enums.SocialAccountType.Steam,
socialAccountIdentifier: 76_561_198_000_000_999UL);
Assert.That(viewer.MissionData.TutorialState, Is.EqualTo(100),
"RegisterViewer (admin-import + Steam-social signup) must produce a post-tutorial " +
"viewer by default. Import requests can override via request.TutorialState; absence " +
"means 'a prod-replica viewer ready for the home screen', NOT 'replay tutorial'.");
}
[Test]
public async Task GetViewerByUdid_returns_viewer_or_null()
{
using var factory = new SVSimTestFactory();
var udid = Guid.NewGuid();
long createdId;
using (var scope = factory.Services.CreateScope())
{
var repo = scope.ServiceProvider.GetRequiredService<IViewerRepository>();
createdId = (await repo.RegisterAnonymousViewer(udid)).Id;
}
using (var scope = factory.Services.CreateScope())
{
var repo = scope.ServiceProvider.GetRequiredService<IViewerRepository>();
var hit = await repo.GetViewerByUdid(udid);
var miss = await repo.GetViewerByUdid(Guid.NewGuid());
Assert.That(hit, Is.Not.Null);
Assert.That(hit!.Id, Is.EqualTo(createdId));
Assert.That(miss, Is.Null);
}
}
[Test]
public async Task LinkSteamToViewer_appends_steam_social_connection()
{
using var factory = new SVSimTestFactory();
var udid = Guid.NewGuid();
const ulong steamId = 76_561_198_900_000_001UL;
long viewerId;
using (var scope = factory.Services.CreateScope())
{
var repo = scope.ServiceProvider.GetRequiredService<IViewerRepository>();
viewerId = (await repo.RegisterAnonymousViewer(udid)).Id;
}
using (var scope = factory.Services.CreateScope())
{
var repo = scope.ServiceProvider.GetRequiredService<IViewerRepository>();
await repo.LinkSteamToViewer(viewerId, steamId);
}
using (var scope = factory.Services.CreateScope())
{
var repo = scope.ServiceProvider.GetRequiredService<IViewerRepository>();
await repo.LinkSteamToViewer(viewerId, steamId); // second call must be a no-op
}
using var verifyScope = factory.Services.CreateScope();
var db = verifyScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var loaded = await db.Viewers
.Include(v => v.SocialAccountConnections)
.FirstAsync(v => v.Id == viewerId);
// Count == 1 proves both the initial append AND that the second LinkSteamToViewer call
// hit the `alreadyLinked` short-circuit (idempotent re-link).
Assert.That(loaded.SocialAccountConnections, Has.Count.EqualTo(1));
Assert.That(loaded.SocialAccountConnections[0].AccountType,
Is.EqualTo(SocialAccountType.Steam));
Assert.That(loaded.SocialAccountConnections[0].AccountId, Is.EqualTo(steamId));
}
}