using System.Net; using AcDream.Core.Combat; using AcDream.Core.Items; using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Spells; using AcDream.Runtime.Entities; using AcDream.Runtime.Gameplay; using AcDream.Runtime.Session; namespace AcDream.Runtime.Tests.Session; public sealed class DirectGameRuntimeCommandAdapterTests { [Fact] public void DirectRouteSendsTypedChatAndPortalAndRejectsOldGeneration() { var operations = new FixtureSessionOperations(); var gameplay = new FixtureGameplayOperations(); using var runtime = new GameRuntime(new GameRuntimeDependencies( gameplay, gameplay, gameplay, gameplay, SessionOperations: operations)); gameplay.Bind(runtime); var resetHost = new FixtureResetHost(); DirectGameRuntimeCommandAdapter? adapter = null; LiveSessionConnectOptions options = new( true, "127.0.0.1", 9000, "account", "password"); var live = new LiveSessionHost( runtime.Session, new LiveSessionHostBindings( new LiveSessionRoutingFactories( _ => new FixtureEventRoute(), session => adapter!.CreateRoute(session)), generation => runtime.ResetGeneration( generation, resetHost), new LiveSessionSelectionBindings( id => runtime.PlayerIdentity.ServerGuid = id, _ => { }, runtime.CommunicationOwner.Chat.SetLocalPlayerGuid, _ => { }, _ => { }, runtime.ActionOwner.Combat.Clear), new LiveSessionEnteredWorldBindings( _ => { }, () => { }, () => { }, _ => { }, () => { }), (_, _, _) => { }, () => { }), options); adapter = new DirectGameRuntimeCommandAdapter(runtime, live); var trace = new RuntimeTraceRecorder(); using IDisposable subscription = runtime.Subscribe(trace); RuntimeSessionStartResult started = adapter.Session.Start(runtime.Generation); RuntimeGenerationToken firstGeneration = runtime.Generation; var gameActions = new List(); operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body); const uint selectedObject = 0x70000001u; runtime.InventoryOwner.Objects.AddOrUpdate(new ClientObject { ObjectId = selectedObject, Type = ItemType.Misc, }); RuntimeCommandResult chat = adapter.Chat.Execute( runtime.Generation, new RuntimeChatCommand( RuntimeChatChannel.Say, "hello")); RuntimeCommandResult portal = adapter.Portal.Execute( runtime.Generation, RuntimePortalCommand.RecallLifestone); runtime.CommunicationOwner.TurbineChat.OnChannelsReceived( allegianceRoom: 0x10u, generalRoom: 0x11u, tradeRoom: 0x12u, lfgRoom: 0x13u, roleplayRoom: 0x14u, olthoiRoom: 0x15u, societyRoom: 0x16u, societyCelestialHandRoom: 0u, societyEldrytchWebRoom: 0u, societyRadiantBloodRoom: 0u); RuntimeCommandResult[] stateAndWireCommands = [ adapter.Selection.SelectObject( runtime.Generation, selectedObject), adapter.Selection.Clear(runtime.Generation), adapter.Movement.SetIntent( runtime.Generation, new MovementInput(Forward: true, Run: true)), adapter.Movement.ClearIntent(runtime.Generation), adapter.Movement.Execute( runtime.Generation, RuntimeMovementCommand.Stop), adapter.Chat.Execute( runtime.Generation, new RuntimeChatCommand( RuntimeChatChannel.Fellowship, "group")), adapter.Chat.Execute( runtime.Generation, new RuntimeChatCommand( RuntimeChatChannel.General, "global")), // CH3 (2026-08-09): Roleplay's room id is populated (0x14u // above) but RuntimeCharacterOptionsState's fresh default omits // HearRoleplayChat (matches ACE's own CharacterOptions2.Default) // — the membership gate must refuse LOCALLY (via AddText, "You // are not listening to the Roleplay channel!") rather than // silently sending or silently dropping. adapter.Chat.Execute( runtime.Generation, new RuntimeChatCommand( RuntimeChatChannel.Roleplay, "should be refused")), adapter.InventoryState.AddShortcut( runtime.Generation, new RuntimeShortcutCommand(0, 0x70000001u, 0u)), adapter.InventoryState.RemoveShortcut( runtime.Generation, 0), adapter.Spellbook.AddFavorite( runtime.Generation, tabIndex: 0, position: 0, spellId: 7u), adapter.Spellbook.RemoveFavorite( runtime.Generation, tabIndex: 0, spellId: 7u), adapter.Spellbook.SetFilter( runtime.Generation, filters: 3u), adapter.Spellbook.ForgetSpell( runtime.Generation, spellId: 7u), adapter.Spellbook.SetDesiredComponent( runtime.Generation, componentId: 11u, amount: 3u), adapter.Spellbook.ClearDesiredComponents( runtime.Generation), adapter.Character.Advance( runtime.Generation, new RuntimeAdvancementCommand( RuntimeAdvancementKind.Attribute, StatId: 1u, Cost: 10u)), adapter.Character.Advance( runtime.Generation, new RuntimeAdvancementCommand( RuntimeAdvancementKind.Vital, StatId: 2u, Cost: 11u)), adapter.Character.Advance( runtime.Generation, new RuntimeAdvancementCommand( RuntimeAdvancementKind.Skill, StatId: 3u, Cost: 12u)), adapter.Character.Advance( runtime.Generation, new RuntimeAdvancementCommand( RuntimeAdvancementKind.TrainSkill, StatId: 4u, Cost: 1u)), adapter.Character.SetSingleOption( runtime.Generation, optionId: 0x26u, value: true), adapter.Social.Execute( runtime.Generation, new RuntimeFriendCommand( RuntimeFriendCommandKind.Add, Name: "Friend")), adapter.Social.Execute( runtime.Generation, new RuntimeFriendCommand( RuntimeFriendCommandKind.Remove, CharacterId: 0x50000003u)), adapter.Social.Execute( runtime.Generation, new RuntimeFriendCommand( RuntimeFriendCommandKind.Clear)), adapter.Social.Execute( runtime.Generation, new RuntimeSquelchCommand( RuntimeSquelchScope.Character, Add: true, CharacterId: 0x50000004u, Name: "Muted")), adapter.Social.Execute( runtime.Generation, new RuntimeSquelchCommand( RuntimeSquelchScope.Account, Add: true, Name: "AccountMuted")), adapter.Social.Execute( runtime.Generation, new RuntimeSquelchCommand( RuntimeSquelchScope.Global, Add: true, MessageType: 2u)), ]; RuntimeSessionStartResult reconnected = adapter.Session.Reconnect(runtime.Generation); RuntimeCommandResult stale = adapter.Chat.Execute( firstGeneration, new RuntimeChatCommand( RuntimeChatChannel.Say, "stale")); RuntimeCommandResult staleMovement = adapter.Movement.SetIntent( firstGeneration, new MovementInput(Forward: true)); Assert.Equal(RuntimeSessionStartStatus.Connected, started.Status); Assert.Equal( RuntimeSessionStartStatus.Connected, reconnected.Status); Assert.True(chat.Accepted); Assert.True(portal.Accepted); Assert.All( stateAndWireCommands, result => Assert.Equal( RuntimeCommandStatus.Accepted, result.Status)); Assert.Equal( RuntimeCommandStatus.StaleGeneration, stale.Status); Assert.Equal( RuntimeCommandStatus.StaleGeneration, staleMovement.Status); Assert.False(runtime.MovementOwner.HasCommandInput); Assert.True(gameActions.Count >= 20); Assert.Contains( runtime.CommunicationOwner.Chat.Snapshot(), entry => entry.Text.Contains( "not listening to the Roleplay channel", StringComparison.OrdinalIgnoreCase)); Assert.Contains( trace.Entries, entry => entry.Kind == RuntimeTraceKind.Command && (entry.Code >> 16) == (int)RuntimeCommandDomain.Chat && entry.Text == "hello"); Assert.Contains( trace.Entries, entry => entry.Kind == RuntimeTraceKind.Command && (entry.Code >> 16) == (int)RuntimeCommandDomain.Portal); Assert.Contains( trace.Entries, entry => entry.Kind == RuntimeTraceKind.Combat); RuntimeTeardownAcknowledgement stopped = adapter.Session.Stop(runtime.Generation); Assert.True(stopped.IsComplete); Assert.False(runtime.Session.IsInWorld); } // ── OP1 (Campaign OP, 2026-08-10): the headless local-write-then-send // seam, SaveOptions, and unknown-id rejection ─────────────────────── [Fact] public void SetSingleOption_AutoSaveId_WritesLocalBitBeforeTheWireSendFires() { (GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) = CreateStartedHarness(); uint? options2AtSendTime = null; operations.Sessions[^1].GameActionCapture = _ => options2AtSendTime ??= runtime.CharacterOwner.Options.Options2; // Options2 default (0x00948700) has HearGeneralChat (0x100) ON; // toggling it OFF exercises the local-write-then-send ordering this // slice fixed on the headless path (lane B §4.4 / lane C §7.4). RuntimeCommandResult result = adapter.Character.SetSingleOption( runtime.Generation, (uint)CharacterOptionId.ListenToGeneralChat, false); Assert.True(result.Accepted); Assert.NotNull(options2AtSendTime); Assert.Equal(0u, options2AtSendTime!.Value & 0x00000100u); Assert.Equal( 0u, runtime.CharacterOwner.Options.Options2 & 0x00000100u); runtime.Dispose(); } [Fact] public void SetSingleOption_BatchedId_MarksDirtyWithoutSendingAnything() { (GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) = CreateStartedHarness(); var gameActions = new List(); operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body); // AutoTarget (0x0D) is batched, default ON — flip it off. RuntimeCommandResult result = adapter.Character.SetSingleOption( runtime.Generation, (uint)CharacterOptionId.AutoTarget, false); Assert.True(result.Accepted); Assert.Empty(gameActions); Assert.True(runtime.CharacterOwner.Options.IsDirty); runtime.Dispose(); } [Fact] public void SetSingleOption_UnknownId_RejectsWithoutSendingAnything() { (GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) = CreateStartedHarness(); var gameActions = new List(); operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body); // 0x35 == CharacterOptions1Default — the whole-default-mask landmine // (wire research §5.4.3), never a real option. RuntimeCommandResult result = adapter.Character.SetSingleOption( runtime.Generation, 0x35u, true); Assert.Equal(RuntimeCommandStatus.Rejected, result.Status); Assert.Empty(gameActions); runtime.Dispose(); } [Fact] public void SaveOptions_FlushesTheDirtyBlobThenNoOpsWhenClean() { (GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) = CreateStartedHarness(); var gameActions = new List(); operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body); // MUST-FIX M1 (OP1 review fix, 2026-08-11): seed the server truth // (as a real session's PlayerDescription would) before the flush — // otherwise TryFlush now refuses outright (see // SaveOptions_RefusesBeforeServerSeed_EvenWhenDirty below). SeedServerOptions(runtime); adapter.Character.SetSingleOption( runtime.Generation, (uint)CharacterOptionId.AutoTarget, false); Assert.True(runtime.CharacterOwner.Options.IsDirty); Assert.Empty(gameActions); RuntimeCommandResult saved = adapter.Character.SaveOptions(runtime.Generation); Assert.True(saved.Accepted); Assert.False(runtime.CharacterOwner.Options.IsDirty); byte[] blob = Assert.Single(gameActions); Assert.Equal( SocialActions.SetCharacterOptionsOpcode, System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian( blob.AsSpan(8))); // S4 (OP1 review fix, blast lens): PrimaryObjectId no longer encodes // whether the flush actually fired — both hosts report the SAME // shape (Accepted, objectId 0). Assert.Equal(0u, saved.ResultObjectId); // A clean module's second SaveOptions sends nothing more. RuntimeCommandResult savedAgain = adapter.Character.SaveOptions(runtime.Generation); Assert.True(savedAgain.Accepted); Assert.Equal(0u, savedAgain.ResultObjectId); Assert.Single(gameActions); runtime.Dispose(); } [Fact] public void SaveOptions_RefusesBeforeServerSeed_EvenWhenDirty_ThenSucceedsAfterSeed() { (GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) = CreateStartedHarness(); var gameActions = new List(); operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body); // MUST-FIX M1 (OP1 review fix, 2026-08-11): CreateStartedHarness's // RuntimeCharacterOptionsState starts at CLIENT constructor defaults // — no PlayerDescription has landed yet (HasServerSeed is false). A // blob flush here would ship acdream's defaults over the // character's real server-side options — the wipe class M1 closes. adapter.Character.SetSingleOption( runtime.Generation, (uint)CharacterOptionId.AutoTarget, false); Assert.True(runtime.CharacterOwner.Options.IsDirty); RuntimeCommandResult saved = adapter.Character.SaveOptions(runtime.Generation); Assert.True(saved.Accepted); Assert.Empty(gameActions); // Nothing lost, nothing sent — the pending change is still pending. Assert.True(runtime.CharacterOwner.Options.IsDirty); // The server's real PlayerDescription lands. S5: the seed // supersedes the (unsent) pending change, so dirty a FRESH change // after the seed to prove the GATE — not the module — was what // refused above. SeedServerOptions(runtime); Assert.False(runtime.CharacterOwner.Options.IsDirty); adapter.Character.SetSingleOption( runtime.Generation, (uint)CharacterOptionId.ShowTooltips, false); RuntimeCommandResult savedAfterSeed = adapter.Character.SaveOptions(runtime.Generation); Assert.True(savedAfterSeed.Accepted); Assert.Single(gameActions); Assert.False(runtime.CharacterOwner.Options.IsDirty); runtime.Dispose(); } // ── MUST-FIX 1 (Campaign OP OP1 review fix, mechanism lens, 2026-08-11): // end-to-end proof that GameRuntime's real wiring — not just // LiveSessionController's hook mechanics in isolation // (LiveSessionControllerTests.cs) — actually auto-flushes the batched // blob from an ordinary Session.Tick() once the 480 s timer is due. [Fact] public void Session_Tick_AutoFlushesTheDirtyBlob_OnceThe480sTimerIsDue() { var clock = new ManualTimeProvider(); (GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) = CreateStartedHarness(clock); var gameActions = new List(); operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body); SeedServerOptions(runtime); adapter.Character.SetSingleOption( runtime.Generation, (uint)CharacterOptionId.AutoTarget, false); Assert.True(runtime.CharacterOwner.Options.IsDirty); // Before the timer: an ordinary tick must not flush. runtime.Session.Tick(); Assert.Empty(gameActions); Assert.True(runtime.CharacterOwner.Options.IsDirty); clock.Advance(RuntimeCharacterOptionsState.AutoSaveDelay + TimeSpan.FromSeconds(1)); runtime.Session.Tick(); byte[] blob = Assert.Single(gameActions); Assert.Equal( SocialActions.SetCharacterOptionsOpcode, System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian( blob.AsSpan(8))); Assert.False(runtime.CharacterOwner.Options.IsDirty); runtime.Dispose(); } [Fact] public void Stop_AutoFlushesTheDirtyBlob_BeforeTheCharacterLogoffRequest() { (GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) = CreateStartedHarness(); var order = new List(); operations.Sessions[^1].GameActionCapture = body => { uint opcode = System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian( body.AsSpan(8)); if (opcode == SocialActions.SetCharacterOptionsOpcode) order.Add("options-blob"); }; SeedServerOptions(runtime); adapter.Character.SetSingleOption( runtime.Generation, (uint)CharacterOptionId.AutoTarget, false); Assert.True(runtime.CharacterOwner.Options.IsDirty); RuntimeTeardownAcknowledgement stopped = adapter.Session.Stop(runtime.Generation); Assert.True(stopped.IsComplete); Assert.Equal(["options-blob"], order); runtime.Dispose(); } private sealed class ManualTimeProvider : TimeProvider { private DateTimeOffset _now = new(2026, 8, 11, 0, 0, 0, TimeSpan.Zero); public override DateTimeOffset GetUtcNow() => _now; public void Advance(TimeSpan elapsed) => _now += elapsed; } private static void SeedServerOptions(GameRuntime runtime) => runtime.CharacterOwner.Options.Replace( runtime.CharacterOwner.Options.Options1, runtime.CharacterOwner.Options.Options2); private static (GameRuntime Runtime, DirectGameRuntimeCommandAdapter Adapter, FixtureSessionOperations Operations) CreateStartedHarness(TimeProvider? timeProvider = null) { var operations = new FixtureSessionOperations(); var gameplay = new FixtureGameplayOperations(); var runtime = new GameRuntime(new GameRuntimeDependencies( gameplay, gameplay, gameplay, gameplay, TimeProvider: timeProvider, SessionOperations: operations)); gameplay.Bind(runtime); var resetHost = new FixtureResetHost(); DirectGameRuntimeCommandAdapter? adapter = null; LiveSessionConnectOptions options = new( true, "127.0.0.1", 9000, "account", "password"); var live = new LiveSessionHost( runtime.Session, new LiveSessionHostBindings( new LiveSessionRoutingFactories( _ => new FixtureEventRoute(), session => adapter!.CreateRoute(session)), generation => runtime.ResetGeneration( generation, resetHost), new LiveSessionSelectionBindings( id => runtime.PlayerIdentity.ServerGuid = id, _ => { }, runtime.CommunicationOwner.Chat.SetLocalPlayerGuid, _ => { }, _ => { }, runtime.ActionOwner.Combat.Clear), new LiveSessionEnteredWorldBindings( _ => { }, () => { }, () => { }, _ => { }, () => { }), (_, _, _) => { }, () => { }), options); adapter = new DirectGameRuntimeCommandAdapter(runtime, live); _ = adapter.Session.Start(runtime.Generation); return (runtime, adapter, operations); } 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, new FixtureTransport()); 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, "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 FixtureResetHost : IRuntimeGenerationResetHost { public void RetireEntityProjection(RuntimeEntityRecord entity) { } public void DrainEntityProjectionBoundary() { } public void CompleteEntityProjectionRetirement() { } } 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 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() { } } }