acdream/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs
Erik 22b86b9ff4 feat(ui): Campaign OP slice OP4 — the Character tab
Binds LayoutDesc 0x21000028 (gmCharacterSettingsUI) through OP2's
template-list mechanism and OP3's OptionPage model: 6 authored group
headers + 50 toggle rows (49 from the 2013 build + D3's "Listen to PK
death messages", AP-193) in research doc §2's authored order, each row
resolved by PlayerOption id through CharacterOptionTable, seeded from
live RuntimeCharacterOptionsState, defaulted from CharacterOptionTable.
ClientDefault (byte-verified against UIOption_Checkbox::SetPlayerOption
@0x00486e80's own GetDefaultOptionValue call — AP-194 updated to confirm
the directive was followed), labels/tooltips resolved by name from
string table 0x23000003 (never hard-coded English), and registered with
OptionsPanelController.CharacterPage. Apply/Reset/Defaults
(0x100001FC/FD/FE) are now wired per-page via a scoped subtree search
(UiElement.FindDescendant, promoted from UiTabPanel) since Character/
Chat/Config each author their own physical instance under the SAME
element ids.

Consumers: Group A (29 ids) wire+store only via the existing
SetSingleCharacterOptionRuntimeCmd/TrySetOption seam. Group B: Display
Timestamps prefixes new transcript lines (RuntimeCommunicationState.
DisplayTimestampsSource); Disable Distance Fog forces FogMode.Off
(WeatherSystem.DisableDistanceFogSource, retiring half of TS-73); Run as
Default Movement inverts the walk-mode modifier's default
(RuntimeLocalPlayerMovementState.RunAsDefaultMovementSource). Group C
re-points AutoTarget/AutoRepeatAttack/ViewCombatTarget
(CharacterOptionCombatSettingsSource), VividTargetingIndicator/
CoordinatesOnRadar/LockUI/AcceptLootPermits from the client-local
GameplaySettings record to the canonical server bit — closing two
previously-unfiled divergences where AutoRepeatAttack and
AcceptCorpseLootingPermissions never reached the wire despite being
retail auto-save ids. TS-73 narrowed to its two still-open cases;
TS-75..TS-80 file the genuine gaps (no day/night force, no weather-
particle/profanity-filter/salvage/housing/pickup-preference subsystem,
fellowship-create's unaudited client-sourced field) rather than
inventing stand-ins.

Conformance: CharacterOptionsPageControllerTests pins all 50 rows
against CharacterOptionTable in both directions (an invented or dropped
row fails the build), the authored group/order row-by-row, and the
build/seed/Apply/Reset/Defaults/wire-publish behavior end-to-end against
the committed fixture. 52 new tests; full solution suite 13,008 passed /
4 skipped / 0 failed (was 12,956/4/0).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 04:31:34 +02:00

1207 lines
46 KiB
C#

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<SpellMetadata>());
state.InstallSpellMetadata(table);
state.InstallSpellMetadata(table);
Assert.Same(table, state.Spellbook.Metadata);
Assert.Throws<InvalidOperationException>(
() => 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<AggregateException>(
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<bool> 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<InvalidOperationException>(() =>
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<string>();
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<InvalidOperationException>(
() => 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);
}
}