using System.Net; using AcDream.Core.Combat; using AcDream.Core.Items; using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; using AcDream.Core.Spells; using AcDream.Runtime.Entities; using AcDream.Runtime.Gameplay; using AcDream.Runtime.Session; using AcDream.Runtime.World; namespace AcDream.Runtime.Tests.Session; public sealed class RuntimeLiveEntitySessionControllerTests { [Fact] public void DirectSinkOwnsCanonicalCreateUpdateDeleteWithoutProjection() { // C3c: the direct sink's Create now enters the canonical initial // residence; this test drives the remote first-entry conductor to // completion (the direct-host pump's job) before the follow-up // Position flows the ordinary post-residence path. using StartedRuntime started = StartRuntime(); GameRuntime runtime = started.Runtime; CommitLandblockCollision(runtime, 0x01010000u); RuntimeFirstEntryDriveController drive = CreateDrive(runtime); using var session = new WorldSession( new IPEndPoint(IPAddress.Loopback, 9000), new FixtureTransport()); // C3c-R1 review R3: the residence route requires a drive-backed // world projection — a content-less (projection-less) controller // keeps the pre-flip legacy registration instead (see // ContentLessDirectSink_KeepsPreFlipLegacyRegistration). var controller = new RuntimeLiveEntitySessionController( runtime, session, worldProjection: new FixtureWorldProjection()); LiveEntitySessionSink sink = controller.CreateSink(); WorldSession.EntitySpawn spawn = Spawn(0x70000001u, incarnation: 1); sink.Spawned(spawn); drive.DriveAll(); Assert.Equal(0, drive.PendingCount); DrainPlacementFifo(runtime); sink.PositionUpdated(new WorldSession.EntityPositionUpdate( spawn.Guid, spawn.Position!.Value with { PositionX = 20f, }, Velocity: null, PlacementId: null, IsGrounded: true, InstanceSequence: 1, PositionSequence: 2, TeleportSequence: 0, ForcePositionSequence: 0)); Assert.Equal(1, runtime.Entities.Count); Assert.Equal(1, runtime.Inventory.ObjectCount); Assert.True( runtime.EntityObjects.Entities.TryGetActive( spawn.Guid, out RuntimeEntityRecord canonical)); Assert.Equal( 20f, canonical.Snapshot.Position!.Value.PositionX); sink.Deleted(new DeleteObject.Parsed(spawn.Guid, 1)); Assert.Equal(0, runtime.Entities.Count); Assert.Equal(0, runtime.Inventory.ObjectCount); Assert.Equal( 0, runtime.EntityObjects.Entities.PendingTeardownCount); } /// /// C3c-R1 review R3: a CONTENT-LESS headless host (validated-legal /// configuration: HeadlessConfigurationLoader accepts a null /// process.content) builds no world projection and no first-entry /// drive. Its Creates must keep the exact pre-flip legacy registration /// — the accepted frame commits directly (FullCellId derives from the /// wire cell at registration), no residence lease ever opens, and the /// entity/residence/drive ledgers stay at zero — because a residence /// with no drive to pump it would park every Create (and every packet /// FIFO'd behind its pending residence) forever. /// [Fact] public void ContentLessDirectSink_KeepsPreFlipLegacyRegistration() { using StartedRuntime started = StartRuntime(); GameRuntime runtime = started.Runtime; using var session = new WorldSession( new IPEndPoint(IPAddress.Loopback, 9000), new FixtureTransport()); var controller = new RuntimeLiveEntitySessionController( runtime, session); LiveEntitySessionSink sink = controller.CreateSink(); WorldSession.EntitySpawn spawn = Spawn(0x70000003u, incarnation: 1); sink.Spawned(spawn); Assert.True( runtime.EntityObjects.Entities.TryGetActive( spawn.Guid, out RuntimeEntityRecord canonical)); Assert.Equal( spawn.Position!.Value.LandblockId, canonical.FullCellId); RuntimeEntityObjectOwnershipSnapshot ownership = runtime.EntityObjects.CaptureOwnership(); Assert.Equal(0, ownership.InitialCreateResidenceLeaseCount); Assert.Equal(0, ownership.FirstEntryDrivePendingCount); Assert.Equal(1, runtime.Entities.Count); Assert.Equal(1, runtime.Inventory.ObjectCount); // Position packets flow the ordinary immediate path — nothing is // FIFO'd behind a pending residence. sink.PositionUpdated(new WorldSession.EntityPositionUpdate( spawn.Guid, spawn.Position!.Value with { PositionX = 20f, }, Velocity: null, PlacementId: null, IsGrounded: true, InstanceSequence: 1, PositionSequence: 2, TeleportSequence: 0, ForcePositionSequence: 0)); Assert.Equal( 20f, canonical.Snapshot.Position!.Value.PositionX); sink.Deleted(new DeleteObject.Parsed(spawn.Guid, 1)); Assert.Equal(0, runtime.Entities.Count); Assert.Equal( 0, runtime.EntityObjects.Entities.PendingTeardownCount); } [Fact] public void DirectSinkCompletesExactPortalAndSendsLoginComplete() { using StartedRuntime started = StartRuntime(); GameRuntime runtime = started.Runtime; const uint playerGuid = 0x50000001u; runtime.PlayerIdentity.ServerGuid = playerGuid; using var session = new WorldSession( new IPEndPoint(IPAddress.Loopback, 9000), new FixtureTransport()); var gameActions = new List(); session.GameActionCapture = body => gameActions.Add(body); var controller = new RuntimeLiveEntitySessionController( runtime, session); LiveEntitySessionSink sink = controller.CreateSink(); WorldSession.EntitySpawn spawn = Spawn(playerGuid, incarnation: 1); sink.Spawned(spawn); Assert.Single(gameActions); Assert.Equal(GameActionLoginComplete.Build(), gameActions[0]); sink.Spawned(spawn); Assert.Single(gameActions); gameActions.Clear(); sink.TeleportStarted(1u); sink.PositionUpdated(new WorldSession.EntityPositionUpdate( playerGuid, spawn.Position!.Value with { LandblockId = 0x01020001u, PositionX = 30f, }, Velocity: null, PlacementId: null, IsGrounded: true, InstanceSequence: 1, PositionSequence: 2, TeleportSequence: 1, ForcePositionSequence: 0)); Assert.Single(gameActions); Assert.Equal(GameActionLoginComplete.Build(), gameActions[0]); Assert.True(runtime.TransitOwner.CaptureOwnership().IsSessionIdle); Assert.True(runtime.Portal.Snapshot.Completed); Assert.Equal(0x01020001u, runtime.Portal.Snapshot.DestinationCell); } [Fact] public void DirectSinkProjectsAcceptedLocalWorldStateThroughOneHostSeam() { using StartedRuntime started = StartRuntime(); GameRuntime runtime = started.Runtime; const uint playerGuid = 0x50000002u; runtime.PlayerIdentity.ServerGuid = playerGuid; using var session = new WorldSession( new IPEndPoint(IPAddress.Loopback, 9000), new FixtureTransport()); session.GameActionCapture = _ => { }; var projection = new FixtureWorldProjection(); var controller = new RuntimeLiveEntitySessionController( runtime, session, worldProjection: projection); LiveEntitySessionSink sink = controller.CreateSink(); WorldSession.EntitySpawn spawn = Spawn(playerGuid, incarnation: 1); sink.Spawned(spawn); sink.TeleportStarted(1u); sink.PositionUpdated(new WorldSession.EntityPositionUpdate( playerGuid, spawn.Position!.Value with { LandblockId = 0x01020001u, PositionX = 30f, }, Velocity: null, PlacementId: null, IsGrounded: true, InstanceSequence: 1, PositionSequence: 2, TeleportSequence: 1, ForcePositionSequence: 0)); Assert.Equal(1, projection.SpawnCount); Assert.Equal(1, projection.PositionCount); Assert.Equal(1, projection.TeleportStartCount); Assert.Equal(1, projection.PrepareCount); Assert.True(projection.LastSpawnWasLocal); Assert.True(projection.LastPositionWasLocal); Assert.Equal( PositionTimestampDisposition.Apply, projection.LastPositionDisposition); Assert.Equal(playerGuid, projection.LastRecord?.ServerGuid); Assert.Equal(0x01020001u, projection.LastDestination.CellId); Assert.True(runtime.TransitOwner.CaptureOwnership().IsSessionIdle); Assert.True(runtime.Portal.Snapshot.Completed); } /// /// C3c-R1 review F6: the drive controller outlives its session routes, /// so "session reset precedes a new route" is an asserted latch, not a /// silent assumption — a second route cannot attach before the prior /// route detached, and a route that never owned the drive cannot clear /// the live route's tracked entries. /// [Fact] public void FirstEntryDriveServesOneRouteAtATimeAndScopesClearToTheOwner() { using StartedRuntime started = StartRuntime(); GameRuntime runtime = started.Runtime; RuntimeFirstEntryDriveController drive = CreateDrive(runtime); var routeA = new object(); var routeB = new object(); drive.AttachRoute(routeA); // Re-attaching the same owner is a no-op; a SECOND route asserts. drive.AttachRoute(routeA); Assert.Throws( () => drive.AttachRoute(routeB)); _ = runtime.EntityObjects.RegisterEntityWithInitialResidence( Spawn(0x70000004u, incarnation: 1), isLocalPlayer: false); Assert.Equal(1, drive.PendingCount); // A non-owner detach (never-attached / displaced route rollback) // must not clear the live route's entries. drive.DetachRoute(routeB); Assert.Equal(1, drive.PendingCount); drive.DetachRoute(routeA); Assert.Equal(0, drive.PendingCount); // After the owner detached, a replacement route may attach. drive.AttachRoute(routeB); drive.DetachRoute(routeB); } [Fact] public void FirstEntryDriveSignalsLocalCompletionOnceAfterCanonicalPlacement() { using StartedRuntime started = StartRuntime(); GameRuntime runtime = started.Runtime; const uint playerGuid = 0x50000003u; runtime.PlayerIdentity.ServerGuid = playerGuid; CommitLandblockCollision(runtime, 0x01010000u); RuntimeFirstEntryDriveController drive = CreateDrive(runtime); var route = new object(); int completed = 0; drive.AttachRoute(route, record => { Assert.Equal(playerGuid, record.ServerGuid); completed++; }); using var session = new WorldSession( new IPEndPoint(IPAddress.Loopback, 9000), new FixtureTransport()); var controller = new RuntimeLiveEntitySessionController( runtime, session, worldProjection: new FixtureWorldProjection()); LiveEntitySessionSink sink = controller.CreateSink(); sink.Spawned(Spawn(playerGuid, incarnation: 1)); Assert.Equal(0, completed); DrainFirstEntry(runtime, drive); Assert.Equal(1, completed); Assert.Equal(0, drive.PendingCount); drive.DetachRoute(route); } /// /// C3c: initial-residence admission requires a live session generation /// (RuntimeInitialCreateResidenceState.CanAcceptCreate), so these direct /// sink tests start one through the same fixture-session shape /// DirectGameRuntimeCommandAdapterTests uses. /// private sealed class StartedRuntime : IDisposable { internal required GameRuntime Runtime { get; init; } internal required LiveSessionHost Live { get; init; } public void Dispose() { _ = Live.Stop(Runtime.Generation); Runtime.Dispose(); } } private static StartedRuntime StartRuntime() { var operations = new FixtureGameplayOperations(); var sessionOperations = new FixtureSessionOperations(); var runtime = new GameRuntime(new GameRuntimeDependencies( operations, operations, operations, operations, SessionOperations: sessionOperations)); operations.Bind(runtime); var resetHost = new FixtureResetHost(); var options = new LiveSessionConnectOptions( true, "127.0.0.1", 9000, "account", "password"); var live = new LiveSessionHost( runtime.Session, new LiveSessionHostBindings( new LiveSessionRoutingFactories( _ => new FixtureEventRoute(), _ => new FixtureCommandRoute()), generation => runtime.ResetGeneration(generation, resetHost), new LiveSessionSelectionBindings( id => runtime.PlayerIdentity.ServerGuid = id, _ => { }, runtime.CommunicationOwner.Chat.SetLocalPlayerGuid, _ => { }, _ => { }, runtime.ActionOwner.Combat.Clear), new LiveSessionEnteredWorldBindings( _ => { }, () => { }, () => { }, _ => { }, () => { }), (_, _, _) => { }, () => { }), options); LiveSessionStartResult startResult = live.Start(options); Assert.Equal(LiveSessionStartStatus.Connected, startResult.Status); Assert.NotEqual(0UL, runtime.Generation.Value); return new StartedRuntime { Runtime = runtime, Live = live }; } private static void CommitLandblockCollision( GameRuntime runtime, uint landblockId) { runtime.EntityObjects.Physics.SetPosition.BeginCollisionGeneration( landblockId, 1UL); runtime.EntityObjects.Physics.Engine.AddLandblock( landblockId, new TerrainSurface(new byte[81], new float[256]), Array.Empty(), Array.Empty(), worldOffsetX: 0f, worldOffsetY: 0f); runtime.EntityObjects.Physics.SetPosition.CommitCollisionGeneration( landblockId, 1UL, ready: true); } private static RuntimeFirstEntryDriveController CreateDrive( GameRuntime runtime) => new( runtime.EntityObjects, runtime.Clock, new UnusedCollisionSource(), () => PlayerMovementConstructionOptions.Fallback, static _ => new RuntimeLocalPlayerPhysicsActivationPreparation( Radius: 0.48f, Height: 1.835f, RuntimeLocalPlayerShadowDisposition.ProvenShapeless)); private static void DrainFirstEntry( GameRuntime runtime, RuntimeFirstEntryDriveController drive) { for (int attempt = 0; attempt < 8 && drive.PendingCount != 0; attempt++) { drive.DriveAll(); DrainPlacementFifo(runtime); } Assert.Equal(0, drive.PendingCount); } /// /// Drains/acknowledges every still-pending placement receipt (the /// ExecutorCompleted correlation is reaped by its acknowledgement) the /// way a host subscription would. /// private static void DrainPlacementFifo(GameRuntime runtime) { while (runtime.EntityObjects.Physics.SetPosition.TryPeekProjection( out AcDream.Runtime.Physics.RuntimePlacementProjectionSnapshot head)) { if (!runtime.EntityObjects.Physics.SetPosition .AcknowledgeProjection(head.Token)) { break; } } } private sealed class UnusedCollisionSource : AcDream.Content.IPreparedCollisionSource { public AcDream.Content.PreparedAssetPresence ProbeCollision( AcDream.Content.Pak.PakAssetType type, uint sourceFileId) => AcDream.Content.PreparedAssetPresence.Available; public AcDream.Content.PreparedCollisionReadResult< AcDream.Core.Physics.FlatSetupCollision> ReadSetupCollision( uint sourceFileId, CancellationToken cancellationToken = default) => AcDream.Content.PreparedCollisionReadResult< AcDream.Core.Physics.FlatSetupCollision>.Missing; public AcDream.Content.PreparedCollisionReadResult< AcDream.Core.Physics.FlatGfxObjCollisionAsset> ReadGfxObjCollision( uint sourceFileId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public AcDream.Content.PreparedCollisionReadResult< AcDream.Core.Physics.FlatCellStructureCollisionAsset> ReadCellStructureCollision( uint sourceFileId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public AcDream.Content.PreparedCollisionReadResult< AcDream.Core.Physics.FlatEnvCellTopology> ReadEnvCellTopology( uint sourceFileId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public AcDream.Content.PreparedCollisionSourceStats CollisionStats => default; public void Dispose() { } } private sealed class FixtureSessionOperations : ILiveSessionOperations { public IPEndPoint ResolveEndpoint(string host, int port) => new(IPAddress.Loopback, port); public WorldSession CreateSession(IPEndPoint endpoint) => new(endpoint, new FixtureTransport()); public void Connect(WorldSession session, string user, string password) { } public CharacterList.Parsed GetCharacters(WorldSession session) => new( 0u, [new CharacterList.Character(0x50000001u, "Direct", 0u)], [], 11, "account", true, true); public void EnterWorld(WorldSession session, int activeCharacterIndex) { } public void Tick(WorldSession session) { } public void DisposeSession(WorldSession session) => session.Dispose(); } private sealed class FixtureEventRoute : ILiveSessionEventRouting { public void Attach() { } public void Dispose() { } } private sealed class FixtureCommandRoute : ILiveSessionCommandRouting { public void Activate() { } public void Dispose() { } } private sealed class FixtureResetHost : IRuntimeGenerationResetHost { public void RetireEntityProjection(RuntimeEntityRecord entity) { } public void DrainEntityProjectionBoundary() { } public void CompleteEntityProjectionRetirement() { } } private static WorldSession.EntitySpawn Spawn( uint guid, ushort incarnation) { var position = new CreateObject.ServerPosition( 0x01010001u, 10f, 10f, 5f, 1f, 0f, 0f, 0f); var timestamps = new PhysicsTimestamps( Position: 1, Movement: 1, State: 1, Vector: 1, Teleport: 0, ServerControlledMove: 1, ForcePosition: 0, ObjDesc: 1, Instance: incarnation); var physics = new PhysicsSpawnData( RawState: (uint)PhysicsStateFlags.ReportCollisions, Position: position, Movement: null, AnimationFrame: null, SetupTableId: null, MotionTableId: null, SoundTableId: null, PhysicsScriptTableId: null, Parent: null, Children: null, Scale: null, Friction: null, Elasticity: null, Translucency: null, Velocity: null, Acceleration: null, AngularVelocity: null, DefaultScriptType: null, DefaultScriptIntensity: null, Timestamps: timestamps); return new WorldSession.EntitySpawn( guid, position, null, [], [], [], null, null, "direct entity", null, null, null, PhysicsState: physics.RawState, InstanceSequence: incarnation, MovementSequence: 1, ServerControlSequence: 1, PositionSequence: 1, Physics: physics); } private sealed class FixtureTransport : IWorldSessionTransport { public void Send(ReadOnlySpan datagram) { } public void Send( IPEndPoint remote, ReadOnlySpan datagram) { } public int Receive( Span destination, TimeSpan timeout, out IPEndPoint? from) { from = null; return -1; } public ValueTask ReceiveAsync( Memory destination, CancellationToken cancellationToken) => ValueTask.FromException( new OperationCanceledException(cancellationToken)); public void Dispose() { } } private sealed class FixtureWorldProjection : IRuntimeDirectWorldProjection { public int SpawnCount { get; private set; } public int PositionCount { get; private set; } public int TeleportStartCount { get; private set; } public int PrepareCount { get; private set; } public bool LastSpawnWasLocal { get; private set; } public bool LastPositionWasLocal { get; private set; } public PositionTimestampDisposition LastPositionDisposition { get; private set; } public RuntimeEntityRecord? LastRecord { get; private set; } public RuntimeTeleportDestination LastDestination { get; private set; } public void ProjectSpawn( RuntimeEntityRecord record, bool isLocalPlayer) { SpawnCount++; LastRecord = record; LastSpawnWasLocal = isLocalPlayer; } public void ProjectPosition( RuntimeEntityRecord record, bool isLocalPlayer, PositionTimestampDisposition disposition) { PositionCount++; LastRecord = record; LastPositionWasLocal = isLocalPlayer; LastPositionDisposition = disposition; } public void BeginTeleport() => TeleportStartCount++; public RuntimeDestinationReadiness PrepareDestination( long revealGeneration, RuntimeTeleportDestination destination) { PrepareCount++; LastDestination = destination; bool indoor = (destination.CellId & 0xFFFFu) >= 0x0100u; return new RuntimeDestinationReadiness( revealGeneration, destination.CellId, indoor, IsUnhydratable: false, RequiredRenderRadius: indoor ? 0 : 1, IsRenderNeighborhoodReady: true, AreCompositeTexturesReady: true, IsCollisionReady: true); } } private sealed class FixtureGameplayOperations : IRuntimeCombatAttackOperations, IRuntimeCombatTargetOperations, IRuntimeCombatModeOperations, IRuntimeSpellCastOperations { private GameRuntime? _runtime; public void Bind(GameRuntime runtime) => _runtime = runtime; public bool CanStartAttack() => false; public void PrepareAttackRequest() { } public bool SendAttack(AttackHeight height, float power) => false; public void SendCancelAttack() { } public bool IsDualWield => false; public bool PlayerReadyForAttack => false; public bool AutoRepeatAttack => false; public bool AutoTarget => false; public uint? SelectClosestTarget() => null; public bool IsInWorld => _runtime?.Session.IsInWorld == true; public IReadOnlyList GetOrderedEquipment() => []; public void NotifyExplicitCombatModeRequest() { } public void SendChangeCombatMode(CombatMode mode) { } public uint LocalPlayerId => _runtime?.PlayerIdentity.ServerGuid ?? 0u; public bool CanSend => false; public bool HasRequiredComponents(uint spellId) => false; public bool IsTargetCompatible( uint targetId, SpellMetadata spell, bool showMessage) => false; public void StopCompletely() { } public void SendUntargeted(uint spellId) { } public void SendTargeted(uint targetId, uint spellId) { } public void DisplayMessage(string message) { } public void IncrementBusy() { } } }