using System.Net; using System.Reflection; using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; using AcDream.Headless.Configuration; using AcDream.Headless.Credentials; using AcDream.Headless.Diagnostics; using AcDream.Headless.Hosting; using AcDream.Runtime; using AcDream.Runtime.Entities; using AcDream.Runtime.Session; namespace AcDream.Headless.Tests; public sealed class HeadlessSessionIsolationTests { [Theory] [InlineData(1)] [InlineData(5)] [InlineData(10)] public void SameServerIdentityStaysIsolatedAcrossActivityPortalAndReconnect( int sessionCount) { const uint sharedPlayerGuid = 0x50000001u; var operations = new FixtureOperations(sharedPlayerGuid); HeadlessSessionHost[] hosts = Enumerable.Range(0, sessionCount) .Select(index => CreateHost(index, operations)) .ToArray(); try { foreach (HeadlessSessionHost host in hosts) { Assert.Equal( RuntimeSessionStartStatus.Connected, host.Start().Status); } var firstRecords = new RuntimeEntityRecord[sessionCount]; for (int index = 0; index < hosts.Length; index++) { HeadlessSessionHost host = hosts[index]; WorldSession session = operations.ActiveSession(host.SessionId); session.GameActionCapture = _ => { }; SpawnInto(session, sharedPlayerGuid, index + 1f); RuntimeCommandResult selected = host.Commands.Selection.SelectObject( host.Runtime.Generation, sharedPlayerGuid); Assert.True(selected.Accepted); Assert.True( host.Runtime.EntityObjects.Entities.TryGetActive( sharedPlayerGuid, out RuntimeEntityRecord record)); firstRecords[index] = record; Assert.Equal( index + 1f, record.Snapshot.Position!.Value.PositionX); Portal( session, sharedPlayerGuid, index + 20f); Assert.True(host.Runtime.Portal.Snapshot.Completed); Assert.True( host.Runtime.TransitOwner .CaptureOwnership() .IsSessionIdle); } Assert.Equal( sessionCount, firstRecords .Distinct(ReferenceEqualityComparer.Instance) .Count()); Assert.Equal( sessionCount, hosts .Select(static host => host.Runtime.Entities) .Distinct(ReferenceEqualityComparer.Instance) .Count()); for (int index = 0; index < hosts.Length; index++) { HeadlessSessionHost host = hosts[index]; ulong priorGeneration = host.Runtime.Generation.Value; Assert.Equal( RuntimeSessionStartStatus.Connected, host.Reconnect().Status); Assert.True( host.Runtime.Generation.Value > priorGeneration); Assert.Equal(0, host.Runtime.Entities.Count); WorldSession replacement = operations.ActiveSession(host.SessionId); replacement.GameActionCapture = _ => { }; SpawnInto( replacement, sharedPlayerGuid, index + 101f); Assert.True( host.Runtime.EntityObjects.Entities.TryGetActive( sharedPlayerGuid, out RuntimeEntityRecord replacementRecord)); Assert.NotSame(firstRecords[index], replacementRecord); Assert.Equal( index + 101f, replacementRecord.Snapshot.Position!.Value.PositionX); } } finally { for (int index = hosts.Length - 1; index >= 0; index--) hosts[index].Dispose(); } Assert.Equal(sessionCount * 2, operations.CreatedSessionCount); Assert.Equal(sessionCount * 2, operations.DisposedSessionCount); Assert.All( hosts, static host => Assert.True(host.Runtime.CaptureOwnership().IsConverged)); } private static HeadlessSessionHost CreateHost( int index, FixtureOperations operations) { string id = $"bot-{index}"; var credential = new HeadlessCredentialSecret( $"{id}-credential", "fixture-password"); try { return new HeadlessSessionHost( new HeadlessSessionDescriptor { Id = id, Endpoint = new HeadlessEndpointDescriptor { Host = "127.0.0.1", Port = 9000, }, Account = id, Character = new HeadlessCharacterSelector { Index = 0, }, Policy = new HeadlessBotPolicyDescriptor { Id = "idle", }, Credential = new HeadlessCredentialReference { Provider = HeadlessCredentialProviderKind.StandardInput, Reference = $"{id}-credential", }, }, credential, new HeadlessDiagnosticWriter(TextWriter.Null), operations); } catch { credential.Dispose(); throw; } } private static void SpawnInto( WorldSession session, uint guid, float positionX) => EventDelegate>( session, nameof(session.EntitySpawned))( Spawn(guid, positionX)); private static void Portal( WorldSession session, uint guid, float positionX) { EventDelegate>( session, nameof(session.TeleportStarted))(1u); EventDelegate>( session, nameof(session.PositionUpdated))( new WorldSession.EntityPositionUpdate( guid, Position(positionX) with { LandblockId = 0x01020001u, }, Velocity: null, PlacementId: null, IsGrounded: true, InstanceSequence: 1, PositionSequence: 2, TeleportSequence: 1, ForcePositionSequence: 0)); } private static WorldSession.EntitySpawn Spawn( uint guid, float positionX) { CreateObject.ServerPosition position = Position(positionX); var timestamps = new PhysicsTimestamps( Position: 1, Movement: 1, State: 1, Vector: 1, Teleport: 0, ServerControlledMove: 1, ForcePosition: 0, ObjDesc: 1, Instance: 1); var physics = new PhysicsSpawnData( RawState: (uint)PhysicsStateFlags.ReportCollisions, Position: position, Movement: null, AnimationFrame: null, SetupTableId: 0x02000001u, 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, 0x02000001u, [], [], [], null, null, "Headless", null, null, null, PhysicsState: physics.RawState, InstanceSequence: 1, MovementSequence: 1, ServerControlSequence: 1, PositionSequence: 1, Physics: physics); } private static CreateObject.ServerPosition Position(float positionX) => new( 0x01010001u, positionX, 10f, 5f, 1f, 0f, 0f, 0f); private static TDelegate EventDelegate( WorldSession session, string eventName) where TDelegate : Delegate => Assert.IsType( typeof(WorldSession).GetField( eventName, BindingFlags.Instance | BindingFlags.NonPublic) ?.GetValue(session)); private sealed class FixtureOperations(uint sharedPlayerGuid) : ILiveSessionOperations { private readonly Dictionary _active = new(StringComparer.Ordinal); public int CreatedSessionCount { get; private set; } public int DisposedSessionCount { get; private set; } public WorldSession ActiveSession(string account) => _active[account]; public IPEndPoint ResolveEndpoint(string host, int port) => new(IPAddress.Loopback, port); public WorldSession CreateSession(IPEndPoint endpoint) { CreatedSessionCount++; return new WorldSession(endpoint, new FixtureTransport()); } public void Connect( WorldSession session, string user, string password) => _active[user] = session; public CharacterList.Parsed GetCharacters( WorldSession session) => new( 0u, [ new CharacterList.Character( sharedPlayerGuid, "Headless", 0u), ], [], 11, "account", true, true); public void EnterWorld( WorldSession session, int activeCharacterIndex) { } public void Tick(WorldSession session) { } public void DisposeSession(WorldSession session) { DisposedSessionCount++; session.Dispose(); } } 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() { } } }