using System.Buffers.Binary; using System.Collections.Concurrent; using System.Diagnostics; using System.Net; using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Headless.Configuration; using AcDream.Headless.Credentials; using AcDream.Headless.Diagnostics; using AcDream.Headless.Hosting; using AcDream.Headless.Platform; using AcDream.Runtime; using AcDream.Runtime.Session; namespace AcDream.Headless.Tests; /// /// Campaign OP slice OP7 (2026-08-11), D8: end-to-end wiring proof that a /// declared characterOptions block reaches the real wire through /// 's actual production seams — not just /// the isolated diff engine ( /// covers that with a fake command surface). A real /// PlayerDescription game event is dispatched through /// WorldSession.GameEvents exactly like a live server connection /// would deliver it (the same mechanism /// LiveSessionEventRouterTests.PlayerDescription_ReplacesOptionsBeforeInvokingOnCharacterOptionsChanged /// uses) — this proves the REAL /// LiveCharacterSessionBindings.OnCharacterOptionsChanged wiring /// added in HeadlessSessionHost.CreateEventRoute. The "LoginComplete /// already sent" half is driven through the /// test seam directly — the three production sites that actually send /// GameActionLoginComplete (content-less immediate admission, direct /// first-entry completion, portal-space materialization completion) are /// each simple one-line delegate wiring already covered by their OWN /// existing tests (RuntimeLiveEntitySessionControllerTests); this /// class's job is to prove what happens once that signal lands, not to /// re-derive it. /// public sealed class HeadlessCharacterOptionsSeederWiringTests { [Fact] public void DeclaredOptionSendsOnceBothLoginCompleteAndTheRealPlayerDescriptionEventLand() { var operations = new FixtureSessionOperations(); using var credential = new HeadlessCredentialSecret( "fixture", "password"); using var host = new HeadlessSessionHost( Descriptor(), credential, new HeadlessDiagnosticWriter(TextWriter.Null), operations); Assert.Equal( RuntimeSessionStartStatus.Connected, host.Start().Status); Assert.NotNull(host.OptionsSeeder); var sent = new List(); WorldSession session = operations.Sessions[^1]; session.GameActionCapture = body => sent.Add(body); // LoginComplete lands first (a legitimate production ordering — // see the type doc); no PlayerDescription has arrived yet, so // HasServerSeed is still false and nothing can send. host.OptionsSeeder!.NoteLoginCompleteSent(); Assert.Empty(sent); // The real PlayerDescription game event, dispatched through the // ACTUAL session event route production installed — proves // OnCharacterOptionsChanged is really wired, not just callable. // IgnoreAllegianceRequests (0x01, Options1 0x00000004) starts OFF // on the server; the declared value is ON. session.GameEvents.Dispatch( GameEventEnvelope.TryParse( WrapPlayerDescriptionEnvelope(options1: 0u, options2: 0u))! .Value); byte[] action = Assert.Single(sent); Assert.Equal( SocialActions.SetSingleCharacterOptionOpcode, ActionOpcode(action)); Assert.Equal( (uint)CharacterOptionId.IgnoreAllegianceRequests, BinaryPrimitives.ReadUInt32LittleEndian(action.AsSpan(12, 4))); Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(action.AsSpan(16, 4))); Assert.True( host.Runtime.CharacterOwner.Options.HasServerSeed); } [Fact] public void ReconnectAgainstAServerThatNowAgreesSendsNothing() { var operations = new FixtureSessionOperations(); using var credential = new HeadlessCredentialSecret( "fixture", "password"); using var host = new HeadlessSessionHost( Descriptor(), credential, new HeadlessDiagnosticWriter(TextWriter.Null), operations); Assert.Equal( RuntimeSessionStartStatus.Connected, host.Start().Status); WorldSession firstSession = operations.Sessions[^1]; var firstSent = new List(); firstSession.GameActionCapture = body => firstSent.Add(body); host.OptionsSeeder!.NoteLoginCompleteSent(); firstSession.GameEvents.Dispatch( GameEventEnvelope.TryParse( WrapPlayerDescriptionEnvelope(options1: 0u, options2: 0u))! .Value); Assert.Single(firstSent); Assert.Equal( RuntimeSessionStartStatus.Connected, host.Reconnect().Status); Assert.NotSame(firstSession, operations.Sessions[^1]); WorldSession secondSession = operations.Sessions[^1]; var secondSent = new List(); secondSession.GameActionCapture = body => secondSent.Add(body); host.OptionsSeeder!.NoteLoginCompleteSent(); // The fresh PlayerDescription now echoes what the bot itself set // last connection — IgnoreAllegianceRequests's mask, ON. secondSession.GameEvents.Dispatch( GameEventEnvelope.TryParse( WrapPlayerDescriptionEnvelope( options1: 0x00000004u, options2: 0u))! .Value); Assert.Empty(secondSent); } /// /// #368: the diff-and-send must run on Runtime's one dedicated update /// thread, exactly like every other gameplay-owner mutation — never a /// new async continuation. Mirrors /// HeadlessProcessSchedulerTests.ProcessHostRunsStartAndEveryTickOnOneDedicatedUpdateThread's /// own proof shape (occupy the calling thread across real ticks so a /// thread-pool migration would be observable), but additionally drives /// a real PlayerDescription dispatch and a login-complete signal FROM /// INSIDE ILiveSessionOperations.Tick — the same call frame /// HeadlessSessionHost.Tick's own Runtime.Session.Tick() /// reaches — so the captured send's thread id is measured at the exact /// point production code would run it, not simulated from the test /// thread. /// [Fact] public async Task DiffAndSendRunsOnTheSameDedicatedUpdateThreadAsEveryTick() { var configuration = new HeadlessConfiguration { Version = 1, Sessions = [Descriptor()], }; var operations = new SeedTriggeringSessionOperations(); using var diagnostics = new StringWriter(); using var host = new HeadlessProcessHost( configuration, HeadlessPathSet.Resolve(new HeadlessPathOverrides()), new System.IO.StringReader("fixture-password" + Environment.NewLine), diagnostics, operations); operations.Host = host.Sessions[0]; using var cancellation = new CancellationTokenSource(); int callerThread = Environment.CurrentManagedThreadId; Task run = host.RunAsync(cancellation.Token); var stopwatch = Stopwatch.StartNew(); while (operations.SentActions.IsEmpty && stopwatch.Elapsed < TimeSpan.FromSeconds(10)) { Thread.Sleep(1); } cancellation.Cancel(); HeadlessExitCode result = await run; Assert.Equal(HeadlessExitCode.Success, result); (byte[] Body, int ThreadId) sent = Assert.Single(operations.SentActions); Assert.Equal( SocialActions.SetSingleCharacterOptionOpcode, ActionOpcode(sent.Body)); Assert.NotEqual(0, sent.ThreadId); Assert.NotEqual(callerThread, sent.ThreadId); Assert.Equal(operations.ConnectThreadId, sent.ThreadId); } private static uint ActionOpcode(byte[] body) => BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8, sizeof(uint))); // Minimal PlayerDescription (0x0013) body carrying only the // CharacterOptions1/2 trailer fields — copied from // LiveSessionEventRouterTests.WrapPlayerDescriptionEnvelope (mirrors // GameEventWiringTests.WireAll_PlayerDescription_PublishesCharacterOptions's // fixture layout). private static byte[] WrapPlayerDescriptionEnvelope( uint options1, uint options2) { var stream = new MemoryStream(); using (var writer = new BinaryWriter( stream, System.Text.Encoding.UTF8, leaveOpen: true)) { writer.Write(0u); // property flags writer.Write(0x52u); // player weenie type writer.Write(0u); // vector flags writer.Write(0u); // has health writer.Write(0x40u); // option flags: CharacterOptions2 writer.Write(options1); writer.Write(0u); // legacy hotbar count writer.Write(0u); // spellbook filters writer.Write(options2); writer.Write(0u); // inventory count writer.Write(0u); // equipped count } byte[] payload = stream.ToArray(); byte[] body = new byte[GameEventEnvelope.HeaderSize + payload.Length]; BinaryPrimitives.WriteUInt32LittleEndian(body, GameEventEnvelope.Opcode); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), 0u); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), 0u); BinaryPrimitives.WriteUInt32LittleEndian( body.AsSpan(12), (uint)GameEventType.PlayerDescription); Array.Copy(payload, 0, body, GameEventEnvelope.HeaderSize, payload.Length); return body; } private static HeadlessSessionDescriptor Descriptor() => new() { Id = "bot", Endpoint = new HeadlessEndpointDescriptor { Host = "127.0.0.1", Port = 9000, }, Account = "account", Character = new HeadlessCharacterSelector { Name = "headless", }, Policy = new HeadlessBotPolicyDescriptor { Id = "idle", }, Credential = new HeadlessCredentialReference { Provider = HeadlessCredentialProviderKind.StandardInput, Reference = "fixture-password", }, CharacterOptions = new Dictionary { ["IgnoreAllegianceRequests"] = true, }, }; private sealed class FixtureSessionOperations : ILiveSessionOperations { public List Sessions { get; } = []; public IPEndPoint ResolveEndpoint(string host, int port) => new(IPAddress.Loopback, port); public WorldSession CreateSession(IPEndPoint endpoint) { var session = new WorldSession(endpoint); Sessions.Add(session); return session; } public void Connect(WorldSession session, string user, string password) { } public CharacterList.Parsed GetCharacters(WorldSession session) => new( 0u, [new CharacterList.Character(0x50000001u, "Headless", 0u)], [], 11, "account", true, true); public void EnterWorld(WorldSession session, int activeCharacterIndex) { } public void Tick(WorldSession session) { } public void DisposeSession(WorldSession session) => session.Dispose(); } /// /// Thread-affinity fixture: on its first call it /// drives login-complete plus a real PlayerDescription dispatch from /// INSIDE the call — the same frame production's /// Runtime.Session.Tick() reaches ILiveSessionOperations.Tick /// from. is wired AFTER construction (the /// does not exist yet when this fixture /// is passed into 's constructor). /// private sealed class SeedTriggeringSessionOperations : ILiveSessionOperations { private int _connectThreadId; private int _dispatched; internal HeadlessSessionHost? Host { get; set; } internal int ConnectThreadId => Volatile.Read(ref _connectThreadId); internal ConcurrentQueue<(byte[] Body, int ThreadId)> SentActions { get; } = new(); public IPEndPoint ResolveEndpoint(string host, int port) => new(IPAddress.Loopback, port); public WorldSession CreateSession(IPEndPoint endpoint) { var session = new WorldSession(endpoint); session.GameActionCapture = body => SentActions.Enqueue( (body, Environment.CurrentManagedThreadId)); return session; } public void Connect(WorldSession session, string user, string password) => Volatile.Write(ref _connectThreadId, Environment.CurrentManagedThreadId); public CharacterList.Parsed GetCharacters(WorldSession session) => new( 0u, [new CharacterList.Character(0x50000001u, "Headless", 0u)], [], 11, "account", true, true); public void EnterWorld(WorldSession session, int activeCharacterIndex) { } public void Tick(WorldSession session) { if (Interlocked.Exchange(ref _dispatched, 1) != 0) return; Host?.OptionsSeeder?.NoteLoginCompleteSent(); session.GameEvents.Dispatch( GameEventEnvelope.TryParse( WrapPlayerDescriptionEnvelope(options1: 0u, options2: 0u))! .Value); } public void DisposeSession(WorldSession session) => session.Dispose(); } }