using AcDream.Core.Items; using AcDream.Core.Net.Messages; using AcDream.Core.Properties; using AcDream.Core.Spells; using AcDream.Core.Player; using AcDream.Runtime.Gameplay; namespace AcDream.Runtime.Tests.Gameplay; public sealed class RuntimeCharacterStateTests { // ── Campaign CH slice CH3 (2026-08-09): IsOlthoiPlayer ── [Fact] public void IsOlthoiPlayer_FalseByDefault_NoHeritageParsedYet() { using var state = new RuntimeCharacterState(); Assert.False(state.IsOlthoiPlayer); } [Theory] [InlineData(12)] // HeritageGroup.Olthoi [InlineData(13)] // HeritageGroup.OlthoiAcid public void IsOlthoiPlayer_TrueForOlthoiHeritageGroups(int heritageGroup) { using var state = new RuntimeCharacterState(); var properties = new PropertyBundle(); properties.Ints[(uint)PropertyInt.HeritageGroup] = heritageGroup; state.LocalPlayer.OnProperties(properties); Assert.True(state.IsOlthoiPlayer); } [Fact] public void IsOlthoiPlayer_FalseForNonOlthoiHeritage() { using var state = new RuntimeCharacterState(); var properties = new PropertyBundle(); properties.Ints[(uint)PropertyInt.HeritageGroup] = 1; // Aluvian state.LocalPlayer.OnProperties(properties); Assert.False(state.IsOlthoiPlayer); } [Fact] public void OwnsOneCoupledSpellbookAndLocalPlayerGraph() { SpellTable table = SpellTable.Create( [ new SpellMetadata( SpellId: 1u, Name: "Test", School: "Life", Family: 0u, IconId: 0u, SpellWords: "", Duration: 60f, ManaCost: 0, IsDebuff: false, IsFellowship: false, Description: "", SortKey: 0, Difficulty: 0, Flags: 0u, Generation: 1, IsFastWindup: false, IsOffensive: false, IsUntargeted: false, Speed: 0f, CasterEffect: 0u, TargetEffect: 0u, TargetMask: 0u, SpellType: 0) ]); using var state = new RuntimeCharacterState(table); state.LocalPlayer.OnAttributeUpdate( atType: 2u, ranks: 90u, start: 10u, xp: 0u); state.LocalPlayer.OnVitalUpdate( vitalId: 7u, ranks: 50u, start: 50u, xp: 0u, current: 150u); state.Spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord( SpellId: 1u, LayerId: 1u, Duration: 60f, CasterGuid: 2u, Bucket: 2u, StatModType: 0u, StatModKey: EnchantmentMath.StatKey.MaxHealth, StatModValue: 25f)); state.Spellbook.SetDesiredComponent(0x68000001u, 12u); Assert.Equal(175u, state.LocalPlayer.GetMaxApprox( AcDream.Core.Player.LocalPlayerState.VitalKind.Health)); Assert.Equal(12u, state.Spellbook.DesiredComponents[0x68000001u]); } [Fact] public void InstallsImmutableMetadataOnceOnTheCanonicalSpellbook() { using var state = new RuntimeCharacterState(); SpellTable table = SpellTable.Create(Array.Empty()); state.InstallSpellMetadata(table); state.InstallSpellMetadata(table); Assert.Same(table, state.Spellbook.Metadata); Assert.Throws( () => state.InstallSpellMetadata(SpellTable.Empty)); } [Fact] public void ResetMethodsPreserveTheOtherHalfOfTheLifetimeGroup() { using var state = new RuntimeCharacterState(); state.Spellbook.OnSpellLearned(7u); state.LocalPlayer.OnVitalUpdate(7u, 1u, 9u, 0u, 10u); state.ResetSpellbook(); Assert.False(state.Spellbook.Knows(7u)); Assert.NotNull(state.LocalPlayer.Get( AcDream.Core.Player.LocalPlayerState.VitalKind.Health)); state.Spellbook.OnSpellLearned(8u); state.ResetLocalPlayer(); Assert.True(state.Spellbook.Knows(8u)); Assert.Null(state.LocalPlayer.Get( AcDream.Core.Player.LocalPlayerState.VitalKind.Health)); } [Fact] public void IndependentRuntimeInstancesNeverShareCharacterState() { using var first = new RuntimeCharacterState(); using var second = new RuntimeCharacterState(); first.Spellbook.OnSpellLearned(7u); first.LocalPlayer.OnVitalUpdate(7u, 1u, 9u, 0u, 10u); Assert.False(second.Spellbook.Knows(7u)); Assert.Null(second.LocalPlayer.Get( AcDream.Core.Player.LocalPlayerState.VitalKind.Health)); } [Fact] public void DisposalReportsObserverFailuresAfterTerminalConvergence() { var state = new RuntimeCharacterState(); state.Spellbook.OnSpellLearned(7u); state.LocalPlayer.OnVitalUpdate(7u, 1u, 9u, 0u, 10u); bool failSpellbook = true; bool failCharacter = true; state.Spellbook.SpellbookChanged += () => { if (failSpellbook) { failSpellbook = false; throw new InvalidOperationException("spellbook"); } }; state.LocalPlayer.CharacterChanged += () => { if (failCharacter) { failCharacter = false; throw new InvalidOperationException("character"); } }; AggregateException error = Assert.Throws( state.Dispose); Assert.Equal(2, error.InnerExceptions.Count); Assert.True(state.IsDisposed); Assert.False(state.Spellbook.Knows(7u)); Assert.Null(state.LocalPlayer.Get( AcDream.Core.Player.LocalPlayerState.VitalKind.Health)); Assert.True(state.CaptureOwnership().IsConverged); state.Dispose(); Assert.True(state.IsDisposed); } [Fact] public void OwnsRetailCharacterOptionsAndMovementSkillProjection() { using var state = new RuntimeCharacterState(); Assert.Equal( RuntimeCharacterOptionsState.DefaultOptions1, state.Options.Options1); Assert.Equal( RuntimeCharacterOptionsState.DefaultOptions2, state.Options.Options2); Assert.False(state.MovementSkills.IsComplete); state.Options.Replace(0x04000000u, 0x12345678u); state.MovementSkills.Update(runSkill: 210, jumpSkill: -1); state.MovementSkills.Update(runSkill: -1, jumpSkill: 165); Assert.True(state.Options.DragItemOnPlayerOpensSecureTrade); Assert.Equal(0x12345678u, state.Options.Options2); Assert.Equal( new RuntimeMovementSkillSnapshot( 210, 165, state.MovementSkills.Revision), state.MovementSkills.Snapshot); Assert.True(state.MovementSkills.IsComplete); state.ResetSession(); Assert.Equal( RuntimeCharacterOptionsState.DefaultOptions1, state.Options.Options1); Assert.Equal( RuntimeCharacterOptionsState.DefaultOptions2, state.Options.Options2); Assert.Equal(-1, state.MovementSkills.RunSkill); Assert.Equal(-1, state.MovementSkills.JumpSkill); } // ── CH4 REJECT-review SHOULD-FIX 4 (2026-08-09) ──────────────────── [Theory] [InlineData(CharacterOptionId.ListenToGeneralChat, PlayerDescriptionParser.CharacterOptions2.HearGeneralChat)] [InlineData(CharacterOptionId.ListenToTradeChat, PlayerDescriptionParser.CharacterOptions2.HearTradeChat)] [InlineData(CharacterOptionId.ListenToLFGChat, PlayerDescriptionParser.CharacterOptions2.HearLFGChat)] [InlineData(CharacterOptionId.ListenToRoleplayChat, PlayerDescriptionParser.CharacterOptions2.HearRoleplayChat)] [InlineData(CharacterOptionId.ListenToSocietyChat, PlayerDescriptionParser.CharacterOptions2.HearSocietyChat)] public void SetOptionBit_Options2Ids_ToggleOnlyTheirOwnBit( CharacterOptionId optionId, PlayerDescriptionParser.CharacterOptions2 bit) { var options = new RuntimeCharacterOptionsState(); options.Replace(options.Options1, 0u); // every Hear*Chat bit off options.SetOptionBit((uint)optionId, true); Assert.Equal((uint)bit, options.Options2 & (uint)bit); Assert.Equal(RuntimeCharacterOptionsState.DefaultOptions1, options.Options1); options.SetOptionBit((uint)optionId, false); Assert.Equal(0u, options.Options2 & (uint)bit); } [Fact] public void SetOptionBit_AllegianceId_TogglesOptions1NotOptions2() { var options = new RuntimeCharacterOptionsState(); options.Replace(0u, options.Options2); // HearAllegianceChat off options.SetOptionBit((uint)CharacterOptionId.ListenToAllegianceChat, true); Assert.Equal( (uint)PlayerDescriptionParser.CharacterOptions1.HearAllegianceChat, options.Options1 & (uint)PlayerDescriptionParser.CharacterOptions1.HearAllegianceChat); options.SetOptionBit((uint)CharacterOptionId.ListenToAllegianceChat, false); Assert.Equal( 0u, options.Options1 & (uint)PlayerDescriptionParser.CharacterOptions1.HearAllegianceChat); } [Fact] public void SetOptionBit_UnrecognizedId_IsANoOp() { var options = new RuntimeCharacterOptionsState(); uint before1 = options.Options1; uint before2 = options.Options2; long beforeRevision = options.Revision; options.SetOptionBit(0xFFFFu, true); Assert.Equal(before1, options.Options1); Assert.Equal(before2, options.Options2); Assert.Equal(beforeRevision, options.Revision); } // ── OP4 (Campaign OP, 2026-08-11): GetOptionBit — the read // counterpart every Character-tab row seed and every re-pointed // Group-C consumer polls. [Theory] [InlineData(CharacterOptionId.AutoTarget)] [InlineData(CharacterOptionId.ViewCombatTarget)] [InlineData(CharacterOptionId.ListenToGeneralChat)] [InlineData(CharacterOptionId.DisableDistanceFog)] public void GetOptionBit_RoundTripsWithSetOptionBit(CharacterOptionId id) { var options = new RuntimeCharacterOptionsState(); options.SetOptionBit((uint)id, true); Assert.True(options.GetOptionBit(id)); Assert.True(options.GetOptionBit((uint)id)); options.SetOptionBit((uint)id, false); Assert.False(options.GetOptionBit(id)); } [Fact] public void GetOptionBit_UnrecognizedId_ReturnsFalse() { var options = new RuntimeCharacterOptionsState(); Assert.False(options.GetOptionBit(0xFFFFu)); } [Fact] public void GetOptionBit_ReflectsReplace_NotJustSetOptionBit() { var options = new RuntimeCharacterOptionsState(); Assert.True(options.GetOptionBit(CharacterOptionId.ListenToGeneralChat)); // default ON options.Replace(options.Options1, 0u); // every Options2 bit off, incl. ListenToGeneralChat Assert.False(options.GetOptionBit(CharacterOptionId.ListenToGeneralChat)); } // ── OP1 (Campaign OP, 2026-08-10): TrySetOption — the shared // local-write-then-send/dirty seam, + the dirty/flush state machine ──── [Fact] public void TrySetOption_AutoSaveId_WritesLocallyThenSendsImmediately_NeverDirties() { var options = new RuntimeCharacterOptionsState(); options.Replace(options.Options1, 0u); // every Options2 Hear*Chat bit off var sent = new List<(uint OptionId, bool Value)>(); bool accepted = options.TrySetOption( (uint)CharacterOptionId.ListenToGeneralChat, true, sendAutoSave: (id, value) => sent.Add((id, value))); Assert.True(accepted); Assert.Equal( (uint)PlayerDescriptionParser.CharacterOptions2.HearGeneralChat, options.Options2 & (uint)PlayerDescriptionParser.CharacterOptions2.HearGeneralChat); Assert.Equal([((uint)CharacterOptionId.ListenToGeneralChat, true)], sent); Assert.False(options.IsDirty); Assert.Null(options.FirstDirtiedAt); } [Fact] public void TrySetOption_BatchedId_WritesLocallyAndMarksDirty_NeverSends() { var options = new RuntimeCharacterOptionsState(); var sent = new List<(uint OptionId, bool Value)>(); // AutoTarget (0x0D) is default-ON per CharacterOptionTable — flip it // off to exercise a real transition. bool accepted = options.TrySetOption( (uint)CharacterOptionId.AutoTarget, false, sendAutoSave: (id, value) => sent.Add((id, value))); Assert.True(accepted); // AutoTarget_CharacterOption = 0x2000 (acclient.h:3417). Assert.Equal(0u, options.Options1 & 0x00002000u); Assert.Empty(sent); Assert.True(options.IsDirty); Assert.NotNull(options.FirstDirtiedAt); } [Fact] public void TrySetOption_UnchangedValue_IsANoOp_MatchingRetailEarlyReturn() { var options = new RuntimeCharacterOptionsState(); var sent = new List<(uint, bool)>(); // AutoTarget defaults ON — re-asserting ON must be a no-op (retail: // an unchanged option produces no notice, no side effect, no send). bool accepted = options.TrySetOption( (uint)CharacterOptionId.AutoTarget, true, sendAutoSave: (id, value) => sent.Add((id, value))); Assert.True(accepted); Assert.Empty(sent); Assert.False(options.IsDirty); } [Fact] public void TrySetOption_UnknownId_ReturnsFalse_NeverInvokesCallback() { var options = new RuntimeCharacterOptionsState(); bool invoked = false; bool accepted = options.TrySetOption(0x35u, true, (_, _) => invoked = true); Assert.False(accepted); Assert.False(invoked); Assert.False(options.IsDirty); } // ── MF-2 (Campaign OP OP1 review fix, 2026-08-11): CPlayerModule:: // OnChanged @0x0059A8E0's fellowship mutual-exclusion side effect ────── [Fact] public void TrySetOption_TurningOnIgnoreFellowshipRequests_ClearsAutoAccept_ClearSendsBeforePrimary() { var options = new RuntimeCharacterOptionsState(); // Arm AutoAcceptFellowshipRequests ON first so there is something // for the recursive clear to actually clear. options.TrySetOption( (uint)CharacterOptionId.FellowshipAutoAcceptRequests, true, (_, _) => { }); var sent = new List<(uint OptionId, bool Value)>(); bool accepted = options.TrySetOption( (uint)CharacterOptionId.IgnoreFellowshipRequests, true, sendAutoSave: (id, value) => sent.Add((id, value))); Assert.True(accepted); // Retail's OnChanged runs the recursive clear (a REAL nested // accessor call, complete with its own immediate 0x0005) BEFORE // returning to finish the outer call's own IsAutoSaveOption branch // — so the clear reaches the wire FIRST. Assert.Equal( [ ((uint)CharacterOptionId.FellowshipAutoAcceptRequests, false), ((uint)CharacterOptionId.IgnoreFellowshipRequests, true), ], sent); Assert.NotEqual(0u, options.Options1 & 0x00000008u); // IgnoreFellowshipRequests set Assert.Equal(0u, options.Options1 & 0x20000000u); // AutoAccept cleared } [Fact] public void TrySetOption_TurningOnAutoAcceptFellowship_ClearsIgnoreRequests_ClearSendsBeforePrimary() { var options = new RuntimeCharacterOptionsState(); options.TrySetOption( (uint)CharacterOptionId.IgnoreFellowshipRequests, true, (_, _) => { }); var sent = new List<(uint OptionId, bool Value)>(); bool accepted = options.TrySetOption( (uint)CharacterOptionId.FellowshipAutoAcceptRequests, true, sendAutoSave: (id, value) => sent.Add((id, value))); Assert.True(accepted); Assert.Equal( [ ((uint)CharacterOptionId.IgnoreFellowshipRequests, false), ((uint)CharacterOptionId.FellowshipAutoAcceptRequests, true), ], sent); Assert.NotEqual(0u, options.Options1 & 0x20000000u); // AutoAccept set Assert.Equal(0u, options.Options1 & 0x00000008u); // IgnoreFellowshipRequests cleared } [Fact] public void TrySetOption_TurningOnFellowshipOption_WhenTheOtherIsAlreadyOff_SendsOnlyThePrimary() { var options = new RuntimeCharacterOptionsState(); // IgnoreFellowshipRequests defaults ON (ClientDefault=true) — flip // it off first so the "turn on" below is a REAL transition. options.TrySetOption( (uint)CharacterOptionId.IgnoreFellowshipRequests, false, (_, _) => { }); var sent = new List<(uint OptionId, bool Value)>(); // FellowshipAutoAcceptRequests already off — the recursive clear's // own TrySetOption call must early-return silently (retail's // accessor's own unchanged-value early return), producing exactly // ONE wire send, not two. bool accepted = options.TrySetOption( (uint)CharacterOptionId.IgnoreFellowshipRequests, true, sendAutoSave: (id, value) => sent.Add((id, value))); Assert.True(accepted); Assert.Equal([((uint)CharacterOptionId.IgnoreFellowshipRequests, true)], sent); } [Fact] public void TrySetOption_TurningOffAFellowshipOption_NeverTriggersTheClear() { var options = new RuntimeCharacterOptionsState(); // Force BOTH bits on directly — SetOptionBit bypasses OnChanged's // side-effect switch entirely, so this reaches a state retail's OWN // accessors (and TrySetOption) can never produce, but one a fresh // PlayerDescription CAN carry (ACE performs no validation/clamping // on these bits, wire research §5.1). options.SetOptionBit((uint)CharacterOptionId.IgnoreFellowshipRequests, true); options.SetOptionBit((uint)CharacterOptionId.FellowshipAutoAcceptRequests, true); var sent = new List<(uint OptionId, bool Value)>(); // Retail's case 2/0x12 only fire "if now true" — turning ONE off // must not touch the other. bool accepted = options.TrySetOption( (uint)CharacterOptionId.IgnoreFellowshipRequests, false, sendAutoSave: (id, value) => sent.Add((id, value))); Assert.True(accepted); Assert.Equal([((uint)CharacterOptionId.IgnoreFellowshipRequests, false)], sent); Assert.NotEqual(0u, options.Options1 & 0x20000000u); // AutoAccept untouched (still on) } [Fact] public void MarkDirty_OnlySecondCallDoesNotPushOutFirstDirtiedAt() { var clock = new ManualTimeProvider(); var options = new RuntimeCharacterOptionsState(clock); options.TrySetOption( (uint)CharacterOptionId.AutoTarget, false, (_, _) => { }); DateTimeOffset? firstStamp = options.FirstDirtiedAt; Assert.NotNull(firstStamp); clock.Advance(TimeSpan.FromSeconds(10)); options.TrySetOption( (uint)CharacterOptionId.ShowTooltips, false, (_, _) => { }); Assert.Equal(firstStamp, options.FirstDirtiedAt); } // ── MUST-FIX M1 (Campaign OP OP1 review fix, 2026-08-11): the server- // seed latch guarding TryFlush/TryFlushIfAutoSaveDue ─────────────────── [Fact] public void TryFlush_RefusesBeforeServerSeed_EvenWhenDirty_ThenSucceedsAfterSeed() { var options = new RuntimeCharacterOptionsState(); Assert.False(options.HasServerSeed); options.TrySetOption( (uint)CharacterOptionId.AutoTarget, false, (_, _) => { }); Assert.True(options.IsDirty); int flushes = 0; Assert.False(options.TryFlush(() => flushes++)); Assert.Equal(0, flushes); // Nothing lost, nothing sent — the pending change is still pending. Assert.True(options.IsDirty); // A real PlayerDescription lands. S5: the seed supersedes the // pending change (retail's own PlayerModule is likewise clobbered by // a wholesale re-seed), so re-dirty AFTER the seed to prove the // GATE (not the module) was what refused above. options.Replace(options.Options1, options.Options2); Assert.True(options.HasServerSeed); Assert.False(options.IsDirty); options.TrySetOption( (uint)CharacterOptionId.AutoTarget, true, (_, _) => { }); Assert.True(options.TryFlush(() => flushes++)); Assert.Equal(1, flushes); } [Fact] public void TryFlushIfAutoSaveDue_RefusesBeforeServerSeed_EvenAtThreshold() { var clock = new ManualTimeProvider(); var options = new RuntimeCharacterOptionsState(clock); options.TrySetOption( (uint)CharacterOptionId.AutoTarget, false, (_, _) => { }); clock.Advance(RuntimeCharacterOptionsState.AutoSaveDelay + TimeSpan.FromSeconds(1)); int flushes = 0; Assert.False(options.TryFlushIfAutoSaveDue(() => flushes++)); Assert.Equal(0, flushes); Assert.True(options.IsDirty); } [Fact] public void ReconnectSequence_ResetSessionClearsSeed_NewReplaceUnblocksFlushAgain() { var options = new RuntimeCharacterOptionsState(); options.Replace(options.Options1, options.Options2); // first session's seed options.TrySetOption( (uint)CharacterOptionId.AutoTarget, false, (_, _) => { }); int flushes = 0; Assert.True(options.TryFlush(() => flushes++)); Assert.Equal(1, flushes); // Simulated reconnect: the generation-reset transaction clears the // seed along with everything else. options.ResetSession(); Assert.False(options.HasServerSeed); // Anything that dirties the module BEFORE the new session's // PlayerDescription arrives must not be flushable yet. options.TrySetOption( (uint)CharacterOptionId.AutoTarget, false, (_, _) => { }); Assert.True(options.IsDirty); Assert.False(options.TryFlush(() => flushes++)); Assert.Equal(1, flushes); Assert.True(options.IsDirty); // The new session's PlayerDescription lands — S5 supersedes the // stale pending change; a FRESH change after the reseed flushes. options.Replace(options.Options1, options.Options2); Assert.True(options.HasServerSeed); Assert.False(options.IsDirty); options.TrySetOption( (uint)CharacterOptionId.ShowTooltips, false, (_, _) => { }); Assert.True(options.TryFlush(() => flushes++)); Assert.Equal(2, flushes); } // ── S5 (Campaign OP OP1 review fix, blast lens, 2026-08-11) ──────────── [Fact] public void Replace_ClearsDirtyState_ServerTruthSupersedesPendingLocalIntent() { var options = new RuntimeCharacterOptionsState(); options.TrySetOption( (uint)CharacterOptionId.AutoTarget, false, (_, _) => { }); Assert.True(options.IsDirty); Assert.NotNull(options.FirstDirtiedAt); options.Replace(0x11111111u, 0x22222222u); Assert.False(options.IsDirty); Assert.Null(options.FirstDirtiedAt); Assert.Equal(0x11111111u, options.Options1); Assert.Equal(0x22222222u, options.Options2); } // ── S2 (Campaign OP OP1 review fix, blast lens, 2026-08-11): the // decide-and-clear/callback-outside-the-lock split ───────────────────── [Fact] public async Task TryFlush_ReleasesTheDirtyGate_DuringTheCallback_SoAConcurrentMarkDirtyDoesNotBlock() { var options = new RuntimeCharacterOptionsState(); options.Replace(options.Options1, options.Options2); options.TrySetOption( (uint)CharacterOptionId.AutoTarget, false, (_, _) => { }); using var callbackEntered = new ManualResetEventSlim(false); using var releaseCallback = new ManualResetEventSlim(false); Task flushTask = Task.Run(() => options.TryFlush(() => { callbackEntered.Set(); releaseCallback.Wait(TimeSpan.FromSeconds(10)); })); Assert.True(callbackEntered.Wait(TimeSpan.FromSeconds(5))); // While the callback above is still blocked and holds NO lock (per // the fix), a concurrent MarkDirty from another thread must // complete promptly. Under the pre-fix shape (callback invoked // INSIDE _dirtyGate) this would block until releaseCallback fires. Task probe = Task.Run(options.MarkDirty); Task probeCompletion = await Task.WhenAny(probe, Task.Delay(TimeSpan.FromSeconds(2))); bool probeCompletedPromptly = ReferenceEquals(probeCompletion, probe); releaseCallback.Set(); bool flushed = await flushTask.WaitAsync(TimeSpan.FromSeconds(5)); Assert.True(flushed); Assert.True(probeCompletedPromptly); // R2 (OP1 re-review, 2026-08-11): the concurrent MarkDirty landed // DURING the callback, so the trailing generation-guarded clear must // NOT erase it — the module stays dirty and that change gets its own // later flush instead of being silently lost. Assert.True(options.IsDirty); } [Fact] public void TryFlush_KeepsModuleDirty_WhenADirtyingChangeLandsInsideTheCallback() { // R2's deterministic same-thread shape (also the re-review's NOTE-6 // re-entrancy case): a MarkDirty issued from INSIDE the flush // callback must survive the trailing clear via the generation token. var options = new RuntimeCharacterOptionsState(); options.Replace(options.Options1, options.Options2); options.TrySetOption( (uint)CharacterOptionId.AutoTarget, false, (_, _) => { }); Assert.True(options.IsDirty); Assert.True(options.TryFlush(options.MarkDirty)); Assert.True(options.IsDirty); // The retained dirty state flushes normally afterwards. Assert.True(options.TryFlush(() => { })); Assert.False(options.IsDirty); } [Fact] public void Replace_WithoutServerSeedArming_DoesNotAuthorizeFlush() { // R3 (OP1 re-review, 2026-08-11): a trailer-truncated // PlayerDescription carries zero placeholder option words — its // Replace installs the words but must NOT arm the flush gate. var options = new RuntimeCharacterOptionsState(); options.Replace(0u, 0u, armServerSeed: false); Assert.False(options.HasServerSeed); // Words are zero after the truncated seed, so flipping TO true is the // change (false would hit retail's unchanged-value no-op). options.TrySetOption( (uint)CharacterOptionId.AutoTarget, true, (_, _) => { }); Assert.True(options.IsDirty); Assert.False(options.TryFlush(() => throw new InvalidOperationException( "a truncated-trailer seed must never authorize a blob flush"))); // A later COMPLETE PlayerDescription arms the gate; after re-dirtying, // the flush proceeds. options.Replace(0x50C4A54Au, 0x00948700u); Assert.True(options.HasServerSeed); options.TrySetOption( (uint)CharacterOptionId.AutoTarget, false, (_, _) => { }); Assert.True(options.TryFlush(() => { })); } [Fact] public void TryFlush_PreservesDirtyState_WhenTheCallbackThrows() { var options = new RuntimeCharacterOptionsState(); options.Replace(options.Options1, options.Options2); options.TrySetOption( (uint)CharacterOptionId.AutoTarget, false, (_, _) => { }); Assert.True(options.IsDirty); Assert.Throws(() => options.TryFlush(() => throw new InvalidOperationException("network down"))); Assert.True(options.IsDirty); } [Fact] public void TryFlush_NoOpWhenClean_FlushesAndClearsWhenDirty() { var options = new RuntimeCharacterOptionsState(); int cleanFlushes = 0; Assert.False(options.TryFlush(() => cleanFlushes++)); Assert.Equal(0, cleanFlushes); options.Replace(options.Options1, options.Options2); // seed (M1) options.TrySetOption( (uint)CharacterOptionId.AutoTarget, false, (_, _) => { }); Assert.True(options.IsDirty); int dirtyFlushes = 0; Assert.True(options.TryFlush(() => dirtyFlushes++)); Assert.Equal(1, dirtyFlushes); Assert.False(options.IsDirty); Assert.Null(options.FirstDirtiedAt); // A second flush on a now-clean module is a no-op — retail's // SaveToServer(force: 0) sends nothing for a clean module. Assert.False(options.TryFlush(() => dirtyFlushes++)); Assert.Equal(1, dirtyFlushes); } [Fact] public void TryFlushIfAutoSaveDue_DoesNotFireBeforeThreshold_FiresAtThreshold() { var clock = new ManualTimeProvider(); var options = new RuntimeCharacterOptionsState(clock); options.Replace(options.Options1, options.Options2); // seed (M1) options.TrySetOption( (uint)CharacterOptionId.AutoTarget, false, (_, _) => { }); int flushes = 0; clock.Advance(RuntimeCharacterOptionsState.AutoSaveDelay - TimeSpan.FromSeconds(1)); Assert.False(options.TryFlushIfAutoSaveDue(() => flushes++)); Assert.True(options.IsDirty); clock.Advance(TimeSpan.FromSeconds(1)); Assert.True(options.TryFlushIfAutoSaveDue(() => flushes++)); Assert.Equal(1, flushes); Assert.False(options.IsDirty); } [Fact] public void ResetSession_ClearsDirtyState() { var options = new RuntimeCharacterOptionsState(); options.TrySetOption( (uint)CharacterOptionId.AutoTarget, false, (_, _) => { }); Assert.True(options.IsDirty); options.ResetSession(); Assert.False(options.IsDirty); Assert.Null(options.FirstDirtiedAt); } // ── S3 (Campaign OP OP1 review fix, blast lens, 2026-08-11): the // combined ownership ledger observes IsDirty ──────────────────────────── [Fact] public void CaptureOwnership_OptionsAreClean_ReflectsOptionsIsDirty_EvenWhenBitsReturnToDefault() { using var state = new RuntimeCharacterState(); Assert.True(state.CaptureOwnership().OptionsAreClean); // AutoTarget (0x0D) defaults ON. Flip off then back on: the WORDS // return to their default value, but m_bDirty was set on the first // (real) transition and never cleared by a flush/reset — exactly // the gap S3 flags: the pre-existing OptionsAreDefaults check alone // cannot see this (it would read true here despite a real pending // save being owed). state.Options.TrySetOption( (uint)CharacterOptionId.AutoTarget, false, (_, _) => { }); state.Options.TrySetOption( (uint)CharacterOptionId.AutoTarget, true, (_, _) => { }); Assert.Equal(RuntimeCharacterOptionsState.DefaultOptions1, state.Options.Options1); Assert.True(state.Options.IsDirty); Assert.False(state.CaptureOwnership().OptionsAreClean); state.ResetSession(); Assert.True(state.CaptureOwnership().OptionsAreClean); } [Fact] public void RuntimeCharacterOwnershipSnapshot_IsConverged_RequiresOptionsAreClean() { // Direct record-level pin: IsConverged must fail on OptionsAreClean // alone, exactly like every other convergence field, even when // every other field is in its converged shape. var converged = new RuntimeCharacterOwnershipSnapshot( IsDisposed: true, InternalSubscriptionsAttached: false, LearnedSpellCount: 0, ActiveEnchantmentCount: 0, DesiredComponentCount: 0, FavoriteSpellCount: 0, VitalCount: 0, AttributeCount: 0, SkillCount: 0, PositionCount: 0, PropertyCount: 0, OptionsAreDefaults: true, MovementSkillsAreReset: true, AutonomyIsDefault: true, OptionsAreClean: true); Assert.True(converged.IsConverged); RuntimeCharacterOwnershipSnapshot dirty = converged with { OptionsAreClean = false }; Assert.False(dirty.IsConverged); } private sealed class ManualTimeProvider : TimeProvider { private DateTimeOffset _now = new(2026, 8, 10, 0, 0, 0, TimeSpan.Zero); public override DateTimeOffset GetUtcNow() => _now; public void Advance(TimeSpan elapsed) => _now += elapsed; } // ── Campaign P Slice P1 (2026-07-30): burden/stamina/vitae-adjusted ─── // ── run/jump skill (pseudocode doc §9) ───────────────────────────── [Fact] public void UpdateMovementSkillBase_NoEnchantments_PushesBaseUnchanged() { using var state = new RuntimeCharacterState(); state.UpdateMovementSkillBase(runSkillBase: 210, jumpSkillBase: 165); Assert.Equal(210, state.MovementSkills.RunSkill); Assert.Equal(165, state.MovementSkills.JumpSkill); } [Fact] public void UpdateMovementSkillBase_VitaeActive_AppliesMultiplierToPushedSkill() { SpellTable table = SpellTableWith((1u, "Vitae", 0u)); using var state = new RuntimeCharacterState(table); state.Spellbook.OnEnchantmentAdded(MakeVitae(spellId: 1u, val: 0.9f)); state.UpdateMovementSkillBase(runSkillBase: 200, jumpSkillBase: 100); // CEnchantmentRegistry::EnchantSkill applies vitae first: 200*0.9=180. Assert.Equal(180, state.MovementSkills.RunSkill); Assert.Equal(90, state.MovementSkills.JumpSkill); } [Fact] public void EnchantmentsChanged_AfterBaseAlreadyPushed_RecomputesWithoutFreshBase() { SpellTable table = SpellTableWith((1u, "Vitae", 0u)); using var state = new RuntimeCharacterState(table); state.UpdateMovementSkillBase(runSkillBase: 200, jumpSkillBase: 100); Assert.Equal(200, state.MovementSkills.RunSkill); // A vitae buff lands mid-session — WITHOUT a fresh PD skill push — // and the produced run skill must still move (pseudocode doc §9's // "Spellbook.EnchantmentsChanged -> RecomputeMovementSkills" wire). state.Spellbook.OnEnchantmentAdded(MakeVitae(spellId: 1u, val: 0.95f)); Assert.Equal(190, state.MovementSkills.RunSkill); } [Fact] public void EnchantmentsChanged_SkillSpecificBuff_AppliesToMatchingSkillOnly() { SpellTable table = SpellTableWith((77u, "Run Buff", 0u)); using var state = new RuntimeCharacterState(table); state.UpdateMovementSkillBase(runSkillBase: 200, jumpSkillBase: 100); state.Spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord( SpellId: 77u, LayerId: 1u, Duration: 60f, CasterGuid: 0u, StatModType: (uint)EnchantmentMath.EnchantmentTypeFlag.Skill, StatModKey: RuntimeCharacterState.RunSkillId, StatModValue: 1.5f, Bucket: 1u)); Assert.Equal(300, state.MovementSkills.RunSkill); // 200 * 1.5 Assert.Equal(100, state.MovementSkills.JumpSkill); // untouched } [Fact] public void MovementSkillAugmentations_UseSameRetailChainAsCharacterSheet() { using var state = new RuntimeCharacterState(); state.LocalPlayer.OnSkillUpdate( RuntimeCharacterState.RunSkillId, ranks: 0u, status: 3u, xp: 0u, init: 0u, resistance: 0u, lastUsed: 0d, formulaBonus: 200u); state.LocalPlayer.OnSkillUpdate( RuntimeCharacterState.JumpSkillId, ranks: 0u, status: 2u, xp: 0u, init: 0u, resistance: 0u, lastUsed: 0d, formulaBonus: 100u); state.UpdateMovementSkillBase(runSkillBase: 200, jumpSkillBase: 100); state.UpdateMovementSkillAugmentations( new PlayerSkillMath.AugmentationBonuses( AllSkills: 2, JackOfAllTrades: true, SkilledSpecialized: 3, SkilledMelee: false, SkilledMissile: false, SkilledMagic: false)); Assert.Equal(213, state.MovementSkills.RunSkill); Assert.Equal(107, state.MovementSkills.JumpSkill); } [Fact] public void ResetSession_ClearsBurdenStaminaAndSkillBase() { using var state = new RuntimeCharacterState(); state.UpdateMovementSkillBase(runSkillBase: 200, jumpSkillBase: 100); state.UpdateMovementSkillAugmentations( new PlayerSkillMath.AugmentationBonuses( AllSkills: 2, JackOfAllTrades: true, SkilledSpecialized: 3, SkilledMelee: false, SkilledMissile: false, SkilledMagic: false)); state.MovementSkills.UpdateBurden(1.5f); state.MovementSkills.UpdateStamina(0); state.ResetSession(); Assert.Equal(-1, state.MovementSkills.RunSkill); Assert.Equal(-1, state.MovementSkills.JumpSkill); Assert.Equal(0f, state.MovementSkills.Burden); Assert.Equal(-1, state.MovementSkills.CurrentStamina); Assert.True(state.CaptureOwnership().MovementSkillsAreReset); // A fresh base push after reset must not still carry the pre-reset // augmentation/vitae/enchantment adjustment (spellbook was cleared too). state.UpdateMovementSkillBase(runSkillBase: 200, jumpSkillBase: 100); Assert.Equal(200, state.MovementSkills.RunSkill); } [Fact] public void CaptureOwnership_BurdenOrStaminaLeftoverBreaksMovementSkillsReset() { using var state = new RuntimeCharacterState(); Assert.True(state.CaptureOwnership().MovementSkillsAreReset); state.MovementSkills.UpdateBurden(0.5f); Assert.False(state.CaptureOwnership().MovementSkillsAreReset); state.MovementSkills.UpdateBurden(0f); Assert.True(state.CaptureOwnership().MovementSkillsAreReset); state.MovementSkills.UpdateStamina(80); Assert.False(state.CaptureOwnership().MovementSkillsAreReset); } // --------------------------------------------------------------- // C0-2: retail CommandInterpreter::autonomy_level/UsePositionFromServer // --------------------------------------------------------------- [Fact] public void AutonomyLevel_DefaultsToFullAndMirrorsRetailUsePositionFromServer() { using var state = new RuntimeCharacterState(); Assert.Equal(RuntimeCharacterState.FullAutonomyLevel, state.AutonomyLevel); Assert.False(state.UsePositionFromServer); Assert.True(state.CaptureOwnership().AutonomyIsDefault); Assert.True(state.TrySetAutonomyLevel(0u)); Assert.Equal(0u, state.AutonomyLevel); Assert.True(state.UsePositionFromServer); Assert.False(state.CaptureOwnership().AutonomyIsDefault); Assert.True(state.TrySetAutonomyLevel(1u)); Assert.True(state.UsePositionFromServer); // Retail's SetAutonomyLevel rejects anything above 2; the level and // the derived UsePositionFromServer gate stay exactly as they were. Assert.False(state.TrySetAutonomyLevel(3u)); Assert.Equal(1u, state.AutonomyLevel); Assert.True(state.TrySetAutonomyLevel(RuntimeCharacterState.FullAutonomyLevel)); Assert.False(state.UsePositionFromServer); Assert.True(state.CaptureOwnership().AutonomyIsDefault); } [Fact] public void ResetSession_RestoresAutonomyLevelToFull() { using var state = new RuntimeCharacterState(); Assert.True(state.TrySetAutonomyLevel(0u)); Assert.True(state.UsePositionFromServer); state.ResetSession(); Assert.Equal(RuntimeCharacterState.FullAutonomyLevel, state.AutonomyLevel); Assert.False(state.UsePositionFromServer); Assert.True(state.CaptureOwnership().AutonomyIsDefault); } private static ActiveEnchantmentRecord MakeVitae(uint spellId, float val) => new( spellId, LayerId: 0u, Duration: -1f, CasterGuid: 0u, StatModType: 0u, StatModKey: 0u, StatModValue: val, Bucket: 4u); private static SpellTable SpellTableWith( params (uint id, string name, uint family)[] rows) { var sb = new System.Text.StringBuilder(); sb.AppendLine("Spell ID,Spell ID [Hex],Name,SortKey,IconId [Hex],Difficulty,Duration,Family,Flags [Hex],Generation,IsDebuff,IsFastWindup,IsFellowship,IsIrresistible,IsOffensive,IsUntargetted,Mana,School,Speed,Spell Words,CasterEffect,TargetEffect,TargetMask [Hex],Type,Description,Unknown1,Unknown2,Unknown3,Unknown4,Unknown5,Unknown6,Unknown7,Unknown8,Unknown9,Unknown10"); foreach ((uint id, string name, uint family) in rows) { sb.Append(id).Append(',').Append("0x").Append(id.ToString("X")).Append(',') .Append(name).Append(",0,0x0,1,1,").Append(family).Append(",0x0,1,False,False,False,False,False,False,1,War Magic,0,Words,0,0,0x0,1,Desc,0,0,0,0,0,0,0,0,0,0") .AppendLine(); } return SpellTable.LoadFromReader(new System.IO.StringReader(sb.ToString())); } [Fact] public void CharacterViewBorrowsExactOwnersWithoutReconstructedState() { using var state = new RuntimeCharacterState(); state.LocalPlayer.OnAttributeUpdate(1u, 40u, 10u, 500u); state.LocalPlayer.OnVitalUpdate(7u, 60u, 20u, 700u, 75u); state.LocalPlayer.OnSkillUpdate( 6u, 30u, 2u, 800u, 10u, 0u, 5d, 12u); state.Spellbook.OnSpellLearned(42u); state.Spellbook.SetFavorite(0, 0, 42u); state.Spellbook.SetDesiredComponent(0x68000001u, 11u); RuntimeCharacterSnapshot summary = state.View.Snapshot; Assert.Equal(1, summary.LearnedSpellCount); Assert.Equal(1, summary.DesiredComponentCount); Assert.Equal(1, summary.SkillCount); Assert.True(state.View.KnowsSpell(42u)); Assert.True(state.View.TryGetFavorite(0, 0, out uint favorite)); Assert.Equal(42u, favorite); Assert.True(state.View.TryGetDesiredComponent( 0x68000001u, out uint desired)); Assert.Equal(11u, desired); Assert.True(state.View.TryGetAttribute( (int)LocalPlayerState.AttributeKind.Strength, out RuntimeAttributeSnapshot attribute)); Assert.Equal(50u, attribute.Current); Assert.True(state.View.TryGetVital( (int)LocalPlayerState.VitalKind.Health, out RuntimeVitalSnapshot vital)); Assert.Equal(75u, vital.Current); Assert.True(state.View.TryGetSkill(6u, out RuntimeSkillSnapshot skill)); Assert.Equal(52u, skill.CurrentLevel); } [Fact] public void TwoRuntimeInstancesIsolateOptionsSkillsAndViewRevisions() { using var first = new RuntimeCharacterState(); using var second = new RuntimeCharacterState(); first.Options.Replace(1u, 2u); first.MovementSkills.Update(100, 200); first.Spellbook.OnSpellLearned(9u); Assert.NotEqual( first.View.Snapshot.Options, second.View.Snapshot.Options); Assert.True(first.View.Snapshot.MovementSkills.IsComplete); Assert.False(second.View.Snapshot.MovementSkills.IsComplete); Assert.True(first.View.Snapshot.SpellbookRevision > 0); Assert.Equal(0, second.View.Snapshot.SpellbookRevision); } [Fact] public void SpellbookCommandsFollowRetailLocalAndOutboundOrder() { using var state = new RuntimeCharacterState(); var order = new List(); state.Spellbook.SpellbookChanged += () => order.Add("local"); state.Spellbook.DesiredComponentsChanged += () => order.Add("local"); Assert.True(state.TryAddFavorite( 0, 0, 42u, () => order.Add("send"))); Assert.Equal(["local", "send"], order); Assert.Equal([42u], state.Spellbook.GetFavorites(0)); order.Clear(); state.SetSpellbookFilter(0x3FFEu, () => order.Add("send")); Assert.Equal(["local", "send"], order); Assert.Equal(0x3FFEu, state.Spellbook.SpellbookFilters); order.Clear(); Assert.True(state.TrySetDesiredComponent( 0x68000001u, 0u, () => order.Add("send"))); Assert.Equal(["send", "local"], order); Assert.True(state.Spellbook.DesiredComponents.ContainsKey( 0x68000001u)); Assert.Equal(0u, state.Spellbook.DesiredComponents[0x68000001u]); order.Clear(); state.ClearDesiredComponents(() => order.Add("send")); Assert.Equal(["send", "local"], order); Assert.Empty(state.Spellbook.DesiredComponents); Assert.Throws( () => state.TrySetDesiredComponent( 0x68000002u, 10u, () => throw new InvalidOperationException("transport"))); Assert.Equal(10u, state.Spellbook.DesiredComponents[0x68000002u]); } [Fact] public void InvalidSpellbookCommandsDoNotPublishOrMutate() { using var state = new RuntimeCharacterState(); int sends = 0; Assert.False(state.TryAddFavorite( 8, 0, 42u, () => sends++)); Assert.False(state.TryRemoveFavorite( -1, 42u, () => sends++)); Assert.False(state.TrySetDesiredComponent( 0u, 1u, () => sends++)); Assert.False(state.TrySetDesiredComponent( 1u, 5001u, () => sends++)); Assert.Equal(0, sends); Assert.Empty(state.Spellbook.GetFavorites(0)); Assert.Empty(state.Spellbook.DesiredComponents); } }