refactor(app): compose session and player startup

Move streaming, live-session, hydration, local-player, combat, and teleport construction behind the typed Phase-7 boundary. Add exact-owner runtime bindings and focused spawn-claim classification so partial startup rolls back without retaining old session targets while preserving the accepted construction and frame dependencies.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-22 18:28:32 +02:00
parent 3573da12e1
commit 7771c07fb6
31 changed files with 1759 additions and 532 deletions

View file

@ -24,6 +24,23 @@ public sealed class LiveCombatAttackOperationsTests
Assert.Throws<InvalidOperationException>(() => slot.Bind(second));
}
[Fact]
public void OwnedBindingReleasesOnlyItsExactOwnerAndAllowsRebind()
{
var slot = new CombatAttackOperationsSlot();
var first = new FakeOperations { CanStartValue = false };
var second = new FakeOperations { CanStartValue = true };
IDisposable firstBinding = slot.BindOwned(first);
Assert.Throws<InvalidOperationException>(() => slot.BindOwned(second));
firstBinding.Dispose();
using IDisposable secondBinding = slot.BindOwned(second);
firstBinding.Dispose();
Assert.True(slot.CanStartAttack());
}
[Fact]
public void CanStartRequiresLiveWorldBeforeTargetResolution()
{
@ -137,7 +154,8 @@ public sealed class LiveCombatAttackOperationsTests
private sealed class FakeOperations : ICombatAttackOperations
{
public bool CanStartAttack() => true;
public bool CanStartValue { get; init; } = true;
public bool CanStartAttack() => CanStartValue;
public void PrepareAttackRequest()
{
}

View file

@ -0,0 +1,160 @@
using AcDream.App.Composition;
using AcDream.App.Streaming;
using DatReaderWriter.DBObjs;
namespace AcDream.App.Tests.Composition;
public sealed class SessionPlayerCompositionTests
{
[Fact]
public void RuntimeBindingsReleaseInReverseAndRetryOnlyFailedEdges()
{
var calls = new List<string>();
var bindings = new SessionPlayerRuntimeBindings();
bindings.Adopt("first", new RetryBinding("first", calls, 0));
bindings.Adopt("second", new RetryBinding("second", calls, 1));
Assert.Throws<AggregateException>(bindings.Dispose);
Assert.Equal(["second", "first"], calls);
bindings.Dispose();
bindings.Dispose();
Assert.Equal(["second", "first", "second"], calls);
Assert.Throws<ObjectDisposedException>(() =>
bindings.Adopt("late", new RetryBinding("late", calls, 0)));
}
[Fact]
public void SpawnClaimClassifierPreservesIndoorBoundaryMemoAndReset()
{
int lookups = 0;
uint lastDid = 0;
var info = new LandBlockInfo { NumCells = 2 };
var classifier = new DatSpawnClaimHydrationClassifier(did =>
{
lookups++;
lastDid = did;
return info;
});
Assert.False(classifier.IsUnhydratable(0x123400FFu));
Assert.Equal(0, lookups);
Assert.False(classifier.IsUnhydratable(0x12340100u));
Assert.Equal(0x1234FFFEu, lastDid);
Assert.False(classifier.IsUnhydratable(0x12340100u));
Assert.Equal(1, lookups);
Assert.False(classifier.IsUnhydratable(0x12340101u));
Assert.True(classifier.IsUnhydratable(0x12340102u));
classifier.Reset();
Assert.True(classifier.IsUnhydratable(0x12340102u));
Assert.Equal(4, lookups);
}
[Fact]
public void MissingOrEmptyLandblockMakesIndoorClaimUnhydratable()
{
var missing = new DatSpawnClaimHydrationClassifier(_ => null);
var empty = new DatSpawnClaimHydrationClassifier(
_ => new LandBlockInfo { NumCells = 0 });
Assert.True(missing.IsUnhydratable(0x12340100u));
Assert.True(empty.IsUnhydratable(0x12340100u));
}
[Fact]
public void GameWindowUsesSessionPhaseAndContainsNoPhaseSevenBody()
{
string root = FindRepoRoot();
string window = File.ReadAllText(Path.Combine(
root,
"src",
"AcDream.App",
"Rendering",
"GameWindow.cs"));
string phase = File.ReadAllText(Path.Combine(
root,
"src",
"AcDream.App",
"Composition",
"SessionPlayerComposition.cs"));
Assert.Contains("new SessionPlayerCompositionPhase(", window);
Assert.DoesNotContain("LandblockStreamer.CreateForRequests(", window);
Assert.DoesNotContain("new AcDream.App.Net.LiveSessionController()", window);
Assert.DoesNotContain(
"new AcDream.App.Streaming.LocalPlayerTeleportController(",
window);
Assert.DoesNotContain("IsSpawnClaimUnhydratable", window);
Assert.Contains("LandblockStreamer.CreateForRequests(", phase);
Assert.Contains("new LiveSessionController()", phase);
Assert.Contains("new LocalPlayerTeleportController(", phase);
Assert.Contains("new DatSpawnClaimHydrationClassifier(", phase);
}
[Fact]
public void ProductionPhaseStartsStreamerBeforeSessionAndTransfersPortalLast()
{
string phase = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Composition",
"SessionPlayerComposition.cs"));
AssertAppearsInOrder(
phase,
"LandblockStreamer.CreateForRequests(",
"streamerLease.Resource.Start();",
"new StreamingController(",
"new WorldRevealCoordinator(",
"new LiveSessionController()",
"new LiveEntityHydrationController(",
"new LiveEntityNetworkUpdateController(",
"new GameplayInputFrameController(",
"new PlayerModeController(",
"d.PortalTunnelFallback.Transfer(",
"d.TeleportSink.BindOwned(localTeleport)",
"_publication.PublishSessionPlayer(result);");
}
private sealed class RetryBinding(
string name,
List<string> calls,
int failures) : IDisposable
{
private int _failures = failures;
public void Dispose()
{
calls.Add(name);
if (_failures-- > 0)
throw new InvalidOperationException("retry");
}
}
private static void AssertAppearsInOrder(string source, params string[] values)
{
int cursor = 0;
foreach (string value in values)
{
int found = source.IndexOf(value, cursor, StringComparison.Ordinal);
Assert.True(found >= 0, $"Missing expected source fragment: {value}");
cursor = found + value.Length;
}
}
private static string FindRepoRoot()
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
return directory.FullName;
directory = directory.Parent;
}
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
}
}

View file

@ -1,6 +1,7 @@
using System.Numerics;
using System.Reflection;
using AcDream.App.Diagnostics;
using AcDream.App.Net;
using AcDream.App.Rendering;
using AcDream.UI.Abstractions;
@ -8,6 +9,24 @@ namespace AcDream.App.Tests.Rendering;
public sealed class DevToolsFramePresenterTests
{
[Fact]
public void CommandBusOwnedBindingReleasesExactlyAndAllowsRebind()
{
var source = new DevToolsCommandBusSource();
using var first = new LiveSessionController();
using var second = new LiveSessionController();
IDisposable firstBinding = source.BindOwned(first);
Assert.Same(first.Commands, source.Current);
Assert.Throws<InvalidOperationException>(() => source.BindOwned(second));
firstBinding.Dispose();
using IDisposable secondBinding = source.BindOwned(second);
firstBinding.Dispose();
Assert.Same(second.Commands, source.Current);
}
[Fact]
public void Frame_PreservesBeginThenMenuPanelsAndDrawDataOrder()
{

View file

@ -61,6 +61,25 @@ public sealed class DevToolsRuntimeSourcesTests
Assert.Throws<ObjectDisposedException>(() => source.Bind(first));
}
[Fact]
public void PlayerModeOwnedBindingReleasesExactlyAndAllowsRebind()
{
var source = new DeferredDevToolsPlayerModeCommands();
var first = new PlayerModeTarget();
var second = new PlayerModeTarget();
IDisposable firstBinding = source.BindOwned(first);
Assert.Throws<InvalidOperationException>(() => source.BindOwned(second));
firstBinding.Dispose();
using IDisposable secondBinding = source.BindOwned(second);
firstBinding.Dispose();
source.ToggleFlyOrChase();
Assert.Equal(0, first.ToggleCalls);
Assert.Equal(1, second.ToggleCalls);
}
[Fact]
public void WorldCountSlotCannotFreezeOrReleaseTheWrongWorld()
{

View file

@ -39,14 +39,24 @@ public sealed class GameWindowRenderLeafCompositionTests
[Fact]
public void Composition_transfers_portal_tunnel_before_constructing_frame_borrowers()
{
string phase = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Composition",
"SessionPlayerComposition.cs"));
string source = GameWindowSource();
AssertAppearsInOrder(
phase,
"d.PortalTunnelFallback.Transfer(",
"new LocalPlayerTeleportController(",
"new LocalPlayerTeleportPresentation(portalTunnel)",
"d.TeleportSink.BindOwned(localTeleport)",
"_publication.PublishSessionPlayer(result);");
AssertAppearsInOrder(
source,
"_portalTunnelFallback.Transfer(",
"new AcDream.App.Streaming.LocalPlayerTeleportController(",
"new AcDream.App.Streaming.LocalPlayerTeleportPresentation(",
"_localPlayerTeleportSink.Bind(_localPlayerTeleport);",
"new SessionPlayerCompositionPhase(",
"new AcDream.App.Rendering.LocalPlayerTeleportRenderStateSource(",
"new AcDream.App.Rendering.RenderFrameResourceController(",
"new AcDream.App.Rendering.PrivatePresentationRenderer(",

View file

@ -217,7 +217,7 @@ public sealed class GameWindowSlice8BoundaryTests
"private AcDream.App.Net.LiveSessionHost");
string body = MethodBody(
"private void OnFramebufferResize(",
"private (uint Claim, bool Unhydratable)? _spawnClaimRangeMemo;");
"private void OnClosing()");
Assert.Contains("=> _framebufferResize.Resize(newSize);", body, StringComparison.Ordinal);
Assert.DoesNotContain("Viewport(", body, StringComparison.Ordinal);
@ -294,8 +294,18 @@ public sealed class GameWindowSlice8BoundaryTests
"new SettingsDevToolsCompositionPhase(",
"this).Compose(platform, hostInputCamera, contentEffectsAudio);",
"new WorldRenderCompositionPhase(",
"_runtimeSettings.BindRuntimeTargets(",
"new SessionPlayerCompositionPhase(",
"_liveSessionHost.Start(_options)");
string sessionPhase = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Composition",
"SessionPlayerComposition.cs"));
Assert.Contains(
"d.Settings.BindRuntimeTargetsOwned(settingsTargets)",
sessionPhase,
StringComparison.Ordinal);
string settingsPhase = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
@ -320,7 +330,8 @@ public sealed class GameWindowSlice8BoundaryTests
AssertAppearsInOrder(
shutdown,
"_runtimeSettings.UnbindViewModel()",
"_runtimeSettings.UnbindRuntimeTargets",
"new ResourceShutdownStage(\"frame borrowers\"",
"bindings.Dispose();",
"_retailUiLease.Dispose()",
"_streamer?.Dispose()",
"_wbDrawDispatcher?.Dispose()",
@ -425,7 +436,6 @@ public sealed class GameWindowSlice8BoundaryTests
"pointer.Dispose();",
"new ResourceShutdownStage(\"session dependents\"",
"pointer.ReleaseMouseLookAfterSessionRetirement();",
"pointer.UnbindGameplayFrame(gameplayFrame);",
"_cameraPointerInput = null;");
AssertAppearsInOrder(
dispose,
@ -472,6 +482,12 @@ public sealed class GameWindowSlice8BoundaryTests
"Composition",
"WorldRenderComposition.cs"));
string livePhase = LivePresentationSource();
string sessionPhase = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Composition",
"SessionPlayerComposition.cs"));
AssertAppearsInOrder(
livePhase,
@ -483,8 +499,13 @@ public sealed class GameWindowSlice8BoundaryTests
load,
"new WorldRenderCompositionPhase(",
"new LivePresentationCompositionPhase(",
"_portalTunnelFallback.Transfer(",
"new SessionPlayerCompositionPhase(",
"_frameGraphs.Publish(updateFrameOrchestrator, renderFrameOrchestrator);");
AssertAppearsInOrder(
sessionPhase,
"d.PortalTunnelFallback.Transfer(",
"new LocalPlayerTeleportController(",
"_publication.PublishSessionPlayer(result);");
AssertAppearsInOrder(
worldPhase,
"lifetime.AcquireTerrainAtlas(",

View file

@ -524,6 +524,27 @@ public sealed class RuntimeSettingsControllerTests
Assert.Equal(1, second.UiLockCalls);
}
[Fact]
public void OwnedRuntimeTargetsReleaseExactlyAndAllowRebind()
{
var events = new List<string>();
RuntimeSettingsController controller = CreateController(events: events);
var first = new FakeRuntimeTargets(events);
var second = new FakeRuntimeTargets(events);
IDisposable firstBinding = controller.BindRuntimeTargetsOwned(first);
Assert.Throws<InvalidOperationException>(() =>
controller.BindRuntimeTargetsOwned(second));
firstBinding.Dispose();
using IDisposable secondBinding = controller.BindRuntimeTargetsOwned(second);
firstBinding.Dispose();
controller.SetUiLocked(true);
Assert.Equal(0, first.UiLockCalls);
Assert.Equal(1, second.UiLockCalls);
}
[Fact]
public void DisplayPersistenceFailureDoesNotPublishStateOrTargets()
{

View file

@ -13,6 +13,25 @@ namespace AcDream.App.Tests.Streaming;
public sealed class LocalPlayerTeleportControllerTests
{
[Fact]
public void DeferredNetworkOwnedBindingReleasesExactlyAndAllowsRebind()
{
var deferred = new DeferredLocalPlayerTeleportNetworkSink();
var first = new FakeNetworkSink();
var second = new FakeNetworkSink();
IDisposable firstBinding = deferred.BindOwned(first);
Assert.Throws<InvalidOperationException>(() => deferred.BindOwned(second));
firstBinding.Dispose();
using IDisposable secondBinding = deferred.BindOwned(second);
firstBinding.Dispose();
deferred.OnTeleportStarted(9);
Assert.Empty(first.Starts);
Assert.Equal([9u], second.Starts);
}
[Fact]
public void DestinationBeforeStart_IsReplayedOnceAfterPresentationActivates()
{
@ -684,6 +703,20 @@ public sealed class LocalPlayerTeleportControllerTests
}
}
private sealed class FakeNetworkSink : ILocalPlayerTeleportNetworkSink
{
public List<uint> Starts { get; } = [];
public void OnTeleportStarted(uint sequence) => Starts.Add(sequence);
public void OfferDestination(
WorldSession.EntityPositionUpdate update,
bool teleportTimestampAdvanced)
{
}
public void ResetSession()
{
}
}
private sealed class FakePresentation : ILocalPlayerTeleportPresentation
{
private readonly List<string> _order;

View file

@ -42,6 +42,29 @@ public sealed class DeferredLiveEntityRuntimeComponentLifecycleTests
Assert.Throws<ArgumentException>(() => bridge.Bind(bridge));
}
[Fact]
public void OwnedBindingReleasesExactlyAndAllowsRebind()
{
var bridge = new DeferredLiveEntityRuntimeComponentLifecycle();
int firstCalls = 0;
int secondCalls = 0;
var first = new DelegateLiveEntityRuntimeComponentLifecycle(
_ => firstCalls++);
var second = new DelegateLiveEntityRuntimeComponentLifecycle(
_ => secondCalls++);
IDisposable firstBinding = bridge.BindOwned(first);
Assert.Throws<InvalidOperationException>(() => bridge.BindOwned(second));
firstBinding.Dispose();
using IDisposable secondBinding = bridge.BindOwned(second);
firstBinding.Dispose();
bridge.TearDown(CreateRecord());
Assert.Equal(0, firstCalls);
Assert.Equal(1, secondCalls);
}
[Fact]
public async Task ConcurrentBind_HasOneWinnerAndPublishesThatOwner()
{

View file

@ -32,6 +32,24 @@ public sealed class LiveEntityHydrationControllerTests
Assert.Throws<InvalidOperationException>(() => bridge.Bind(_ => false));
}
[Fact]
public void ParentAcceptanceOwnedBindingReleasesExactlyAndAllowsRebind()
{
var bridge = new DeferredLiveEntityParentAcceptance();
Func<ParentEvent.Parsed, bool> first = _ => true;
Func<ParentEvent.Parsed, bool> second = _ => false;
IDisposable firstBinding = bridge.BindOwned(first);
Assert.True(bridge.TryAccept(default));
Assert.Throws<InvalidOperationException>(() => bridge.BindOwned(second));
firstBinding.Dispose();
using IDisposable secondBinding = bridge.BindOwned(second);
firstBinding.Dispose();
Assert.False(bridge.TryAccept(default));
}
[Fact]
public void FreshCreate_PublishesCanonicalOrderAndReadyAfterMaterialization()
{
@ -1580,6 +1598,25 @@ public sealed class LiveEntityHydrationControllerTests
deferred.Bind(new RecordingNetworkSink()));
}
[Fact]
public void DeferredNetworkOwnedBindingReleasesExactlyAndAllowsRebind()
{
var deferred = new DeferredLiveEntityNetworkUpdateSink();
var first = new RecordingNetworkSink();
var second = new RecordingNetworkSink();
IDisposable firstBinding = deferred.BindOwned(first);
Assert.Throws<InvalidOperationException>(() => deferred.BindOwned(second));
firstBinding.Dispose();
using IDisposable secondBinding = deferred.BindOwned(second);
firstBinding.Dispose();
deferred.ApplySameGeneration(default);
Assert.Equal(0, first.ApplyCount);
Assert.Equal(1, second.ApplyCount);
}
private const uint Guid = 0x70000001u;
private const uint Cell = 0x01010001u;

View file

@ -343,9 +343,15 @@ public sealed class UpdateFrameOrchestratorTests
"AcDream.App",
"Composition",
"LivePresentationComposition.cs"));
string sessionPlayer = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Composition",
"SessionPlayerComposition.cs"));
Assert.Contains("new AcDream.App.Update.LiveObjectFrameController(", source);
Assert.Contains("new AcDream.App.Update.LiveSpatialPresentationReconciler(", source);
Assert.Contains("new LiveObjectFrameController(", sessionPlayer);
Assert.Contains("new LiveSpatialPresentationReconciler(", sessionPlayer);
Assert.Contains("new AcDream.App.World.RetailLiveFrameCoordinator(", source);
Assert.DoesNotContain("AdvanceLiveObjectRuntime", source, StringComparison.Ordinal);
Assert.DoesNotContain("ReconcileLiveObjectSpatialPresentation", source,
@ -363,8 +369,14 @@ public sealed class UpdateFrameOrchestratorTests
"AcDream.App",
"Rendering",
"GameWindow.cs"));
string sessionPlayer = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Composition",
"SessionPlayerComposition.cs"));
Assert.Contains("new AcDream.App.Streaming.StreamingFrameController(", source);
Assert.Contains("new StreamingFrameController(", sessionPlayer);
Assert.DoesNotContain("_streamingFrame", source, StringComparison.Ordinal);
Assert.Equal(1, CountOccurrences(
source,
@ -385,8 +397,14 @@ public sealed class UpdateFrameOrchestratorTests
"AcDream.App",
"Rendering",
"GameWindow.cs"));
string sessionPlayer = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Composition",
"SessionPlayerComposition.cs"));
Assert.Contains("new AcDream.App.Input.GameplayInputFrameController(", source);
Assert.Contains("new GameplayInputFrameController(", sessionPlayer);
Assert.DoesNotContain("_gameplayInputFrame!.Tick", source,
StringComparison.Ordinal);
Assert.DoesNotContain("_inputDispatcher?.Tick()", source,
@ -468,10 +486,16 @@ public sealed class UpdateFrameOrchestratorTests
"AcDream.App",
"Physics",
"LiveEntityNetworkUpdateController.cs"));
string sessionPlayer = File.ReadAllText(Path.Combine(
root,
"src",
"AcDream.App",
"Composition",
"SessionPlayerComposition.cs"));
Assert.Contains(
"new AcDream.App.Streaming.LocalPlayerTeleportController(",
source,
"new LocalPlayerTeleportController(",
sessionPlayer,
StringComparison.Ordinal);
Assert.DoesNotContain("_localPlayerTeleport!.Tick", source,
StringComparison.Ordinal);
@ -491,7 +515,7 @@ public sealed class UpdateFrameOrchestratorTests
AssertAppearsInOrder(
source,
"new AcDream.App.Update.LiveEntityLivenessFramePhase(",
"_localPlayerTeleport,",
"sessionPlayer.LocalTeleport,",
"new AcDream.App.Update.PlayerModeAutoEntryFramePhase(",
"cameraFrame);");
}
@ -799,7 +823,7 @@ public sealed class UpdateFrameOrchestratorTests
StringComparison.Ordinal);
Assert.Contains("d.WorldOrigin.CellLocalForSeed", livePresentation,
StringComparison.Ordinal);
Assert.Contains("_liveEntitySessionEvents.CreateSink()", source,
Assert.Contains("Live entity session routing was not composed.", source,
StringComparison.Ordinal);
}