using AcDream.Core.Net.Messages; using AcDream.Headless.Hosting; using AcDream.Runtime; using AcDream.Runtime.Gameplay; namespace AcDream.Headless.Tests; /// /// Campaign OP slice OP7 (2026-08-11), D8: the declared-vs-actual diff engine /// () unit-tested against a fake /// so these tests never need a live /// session, a wire, or a physics/collision fixture — only a bare /// for its CharacterOwner.Options state and /// Generation token. /// stands in for "a PlayerDescription just landed" exactly like /// LiveSessionEventRouter's own onCharacterOptions wiring does. /// public sealed class HeadlessCharacterOptionsSeederTests { // AutoRepeatAttack (0x00, Options1 0x00000002) is auto-save — sends // SetSingleOption (0x0005) immediately, per CharacterOptionTable. // IgnoreTradeRequests (0x03, Options1 0x00020000) is batched — dirties // the module; only SaveOptions flushes it. Both are exercised by name // throughout this file's tests. private const uint AutoSaveMask = 0x00000002u; [Fact] public void DeclaredEqualsActualSendsNothing() { using GameRuntime runtime = NewRuntime(); runtime.CharacterOwner.Options.Replace(0u, 0u); var commands = new FakeCharacterCommands(runtime.CharacterOwner.Options); var seeder = new HeadlessCharacterOptionsSeeder( new Dictionary { [CharacterOptionId.AutoRepeatAttack] = false, }, runtime, commands); seeder.NoteLoginCompleteSent(); seeder.NoteOptionsSeeded(); Assert.Empty(commands.CallOrder); } [Fact] public void AutoSaveDifferenceSendsExactlyOneSetSingleOption() { using GameRuntime runtime = NewRuntime(); runtime.CharacterOwner.Options.Replace(0u, 0u); var commands = new FakeCharacterCommands(runtime.CharacterOwner.Options); var seeder = new HeadlessCharacterOptionsSeeder( new Dictionary { [CharacterOptionId.AutoRepeatAttack] = true, }, runtime, commands); seeder.NoteLoginCompleteSent(); seeder.NoteOptionsSeeded(); var call = Assert.Single(commands.SingleOptionCalls); Assert.Equal((uint)CharacterOptionId.AutoRepeatAttack, call.OptionId); Assert.True(call.Value); Assert.Equal(0, commands.SaveOptionsCallCount); Assert.Equal(["set:0"], commands.CallOrder); } [Fact] public void BatchedDifferenceSendsSetSingleOptionThenExactlyOneSaveOptionsAtTheEnd() { using GameRuntime runtime = NewRuntime(); runtime.CharacterOwner.Options.Replace(0u, 0u); var commands = new FakeCharacterCommands(runtime.CharacterOwner.Options); var seeder = new HeadlessCharacterOptionsSeeder( new Dictionary { [CharacterOptionId.IgnoreTradeRequests] = true, }, runtime, commands); seeder.NoteLoginCompleteSent(); seeder.NoteOptionsSeeded(); var call = Assert.Single(commands.SingleOptionCalls); Assert.Equal((uint)CharacterOptionId.IgnoreTradeRequests, call.OptionId); Assert.True(call.Value); Assert.Equal(1, commands.SaveOptionsCallCount); Assert.Equal(["set:3", "save"], commands.CallOrder); } [Fact] public void MixedDeclarationOrdersAllSetsBeforeTheSingleFlush() { using GameRuntime runtime = NewRuntime(); runtime.CharacterOwner.Options.Replace(0u, 0u); var commands = new FakeCharacterCommands(runtime.CharacterOwner.Options); var seeder = new HeadlessCharacterOptionsSeeder( new Dictionary { // Declared out of id order on purpose — the seeder sorts // id-ascending internally, so the batched id (0x03) is // expected AFTER the auto-save id (0x00) regardless of // declaration order, with the flush always last. [CharacterOptionId.IgnoreTradeRequests] = true, [CharacterOptionId.AutoRepeatAttack] = true, }, runtime, commands); seeder.NoteLoginCompleteSent(); seeder.NoteOptionsSeeded(); Assert.Equal(2, commands.SingleOptionCalls.Count); Assert.Equal(1, commands.SaveOptionsCallCount); Assert.Equal(["set:0", "set:3", "save"], commands.CallOrder); } [Fact] public void NeitherPreconditionAloneSendsAnything() { using GameRuntime runtime = NewRuntime(); var commands = new FakeCharacterCommands(runtime.CharacterOwner.Options); var seeder = new HeadlessCharacterOptionsSeeder( new Dictionary { [CharacterOptionId.AutoRepeatAttack] = true, }, runtime, commands); // Login-complete alone: HasServerSeed is still false (no Replace // ever ran), so the diff cannot trust live state yet. seeder.NoteLoginCompleteSent(); Assert.Empty(commands.CallOrder); // Now the seed lands — both preconditions are satisfied and the // deferred diff runs from THIS call. runtime.CharacterOwner.Options.Replace(0u, 0u); seeder.NoteOptionsSeeded(); Assert.Single(commands.SingleOptionCalls); } [Fact] public void OptionsSeededBeforeLoginCompleteDefersUntilLoginCompleteArrives() { using GameRuntime runtime = NewRuntime(); runtime.CharacterOwner.Options.Replace(0u, 0u); var commands = new FakeCharacterCommands(runtime.CharacterOwner.Options); var seeder = new HeadlessCharacterOptionsSeeder( new Dictionary { [CharacterOptionId.AutoRepeatAttack] = true, }, runtime, commands); seeder.NoteOptionsSeeded(); Assert.Empty(commands.CallOrder); seeder.NoteLoginCompleteSent(); Assert.Single(commands.SingleOptionCalls); } [Fact] public void EmptyDeclarationNeverSendsRegardlessOfBothSignals() { using GameRuntime runtime = NewRuntime(); runtime.CharacterOwner.Options.Replace(0xFFFFFFFFu, 0xFFFFFFFFu); var commands = new FakeCharacterCommands(runtime.CharacterOwner.Options); var seeder = new HeadlessCharacterOptionsSeeder( new Dictionary(), runtime, commands); Assert.False(seeder.HasDeclaredOptions); seeder.NoteLoginCompleteSent(); seeder.NoteOptionsSeeded(); Assert.Empty(commands.CallOrder); } /// /// D8 idempotence — the acceptance bar is "by construction", not a /// special-cased dedupe: a fresh, second seeder instance (mirroring the /// fresh instance HeadlessSessionHost.CreateEventRoute constructs /// per reconnect) diffs against the SAME declared dictionary but against /// live state that already reflects what the first seeder sent — the /// second diff simply finds nothing to do. /// [Fact] public void ReconnectWithSameDeclarationsAgainstServerThatNowAgreesSendsNothing() { using GameRuntime runtime = NewRuntime(); var declared = new Dictionary { [CharacterOptionId.AutoRepeatAttack] = true, }; // First connect: server has the option OFF; the bot's declared // value differs, so it sends once. runtime.CharacterOwner.Options.Replace(0u, 0u); var firstCommands = new FakeCharacterCommands(runtime.CharacterOwner.Options); var firstSeeder = new HeadlessCharacterOptionsSeeder( declared, runtime, firstCommands); firstSeeder.NoteLoginCompleteSent(); firstSeeder.NoteOptionsSeeded(); Assert.Single(firstCommands.SingleOptionCalls); // Reconnect: a fresh seeder instance (per CreateEventRoute's own // per-route construction), and the server's fresh PlayerDescription // now echoes what the bot itself set last time. runtime.CharacterOwner.Options.ResetSession(); runtime.CharacterOwner.Options.Replace(AutoSaveMask, 0u); var secondCommands = new FakeCharacterCommands(runtime.CharacterOwner.Options); var secondSeeder = new HeadlessCharacterOptionsSeeder( declared, runtime, secondCommands); secondSeeder.NoteLoginCompleteSent(); secondSeeder.NoteOptionsSeeded(); Assert.Empty(secondCommands.CallOrder); } private static GameRuntime NewRuntime() { var gameplay = new HeadlessGameplayOperations(); var runtime = new GameRuntime(new GameRuntimeDependencies( gameplay, gameplay, gameplay, gameplay)); gameplay.Bind(runtime, catalog: null, () => "account"); return runtime; } /// /// Writes the bit into as part of /// , mirroring the REAL /// DirectGameRuntimeCommandAdapter.SetSingleOption → /// RuntimeCharacterOptionsState.TrySetOption contract: the local /// copy updates synchronously, in the same call, before the method /// returns. Without this, a fake that only records the call (never /// mutating live state) would make a SECOND diff pass — e.g. a /// legitimate redundant NoteLoginCompleteSent/NoteOptionsSeeded /// pairing — re-find the same already-handled mismatch and re-send it, /// which is a fidelity gap in the fake, not a real defect: production's /// idempotence depends on exactly this synchronous local write. /// private sealed class FakeCharacterCommands( RuntimeCharacterOptionsState options) : IRuntimeCharacterCommands { internal List<(uint OptionId, bool Value)> SingleOptionCalls { get; } = []; internal int SaveOptionsCallCount { get; private set; } internal List CallOrder { get; } = []; public RuntimeCommandResult Advance( RuntimeGenerationToken expectedGeneration, in RuntimeAdvancementCommand command) => throw new NotSupportedException( "HeadlessCharacterOptionsSeeder never calls Advance."); public RuntimeCommandResult SetSingleOption( RuntimeGenerationToken expectedGeneration, uint optionId, bool value) { SingleOptionCalls.Add((optionId, value)); CallOrder.Add($"set:{optionId}"); options.SetOptionBit(optionId, value); return new RuntimeCommandResult( RuntimeCommandStatus.Accepted, expectedGeneration); } public RuntimeCommandResult SaveOptions( RuntimeGenerationToken expectedGeneration) { SaveOptionsCallCount++; CallOrder.Add("save"); return new RuntimeCommandResult( RuntimeCommandStatus.Accepted, expectedGeneration); } } }