Closes the two mechanism-lens and blast-lens dual reviews of Campaign OP
slice OP1 (86c0a7e0): docs/research/2026-08-10-op1-review-mechanism.md and
docs/research/2026-08-10-op1-review-blast.md.
MUST-FIX M1 (blast): RuntimeCharacterOptionsState gains a HasServerSeed
latch, set by Replace (the PlayerDescription seed) and cleared by
ResetSession. TryFlush/TryFlushIfAutoSaveDue now refuse before the seed
arrives — closing the window where a bot (or, after this commit, the
timer/logout triggers) could flush client-default option words over a
character's real server-side options before any PlayerDescription ever
landed.
MUST-FIX 1 (mechanism): the 480 s auto-save timer and the pre-logoff
flush are now wired into production, closing TS-71 (retired). Both ride
LiveSessionController's own tick/stop transaction via two new hooks
(ConfigureAutoSaveTick/ConfigurePreLogoffFlush), wired once by
GameRuntime's constructor — a Runtime-internal change requiring zero
host edits, exactly as the review identified. The flush body talks to
WorldSession directly rather than through App's LiveSessionCommandRouter,
which is what keeps this off the S2 lock-order hazard (below). Filed
TS-73 for the two OnChanged side-effect cases (weather/day/combat-
target/fog) TrySetOption still doesn't model — pre-anchored to OP4's
Group B consumer binds.
SHOULD-FIX S2 (blast, prerequisite for MUST-FIX 1): TryFlush/
TryFlushIfAutoSaveDue no longer invoke the flush callback while holding
_dirtyGate — the decision is made and cleared under the lock, but the
callback itself runs outside it, closing the lock-inversion hazard the
natural timer wiring would have hit (Runtime tick's _dirtyGate-then-
_gate vs the router's _gate-then-_dirtyGate).
SHOULD-FIX MF-2 (mechanism): TrySetOption now ports the two
PlayerModule-state-mutating cases of CPlayerModule::OnChanged's local
side-effect switch — turning ON IgnoreFellowshipRequests or
FellowshipAutoAcceptRequests clears the other through a real recursive
TrySetOption call, reproducing retail's second 0x0005 (the clear's send
reaches the wire before the primary option's own send, matching the
nested-call order in the decomp). The signature widened from
Action sendAutoSave to Action<uint,bool> so the recursion can send a
different (id, value) than the caller's own; every production call site
now passes WorldSession.SendSetSingleCharacterOption directly.
SHOULD-FIX MF-3 (mechanism): a hand-transcribed 53-row (id, isOptions1,
mask) theory in CharacterOptionTableTests, independently re-derived from
acclient.h's PlayerOption/CharacterOption/CharacterOptions2 enums rather
than copied from CharacterOptionTable.cs — closes the one column with no
id-by-id pin. Also added the pairwise-distinctness check blast NOTE N7
named.
SHOULD-FIX S1 (blast): LiveSessionCommandRouterTests' CH3/CH4 regression
test now drives the REAL TrySetOption binding instead of a hand-rolled
SetOptionBit substitute that had silently drifted from production after
OP1.
SHOULD-FIX S3 (blast): RuntimeCharacterOwnershipSnapshot gains
OptionsAreClean (!Options.IsDirty), included in IsConverged — a module
whose two words happen to cycle back to their default bit pattern while
still dirty is now caught by the combined ownership ledger, not just by
OptionsAreDefaults.
SHOULD-FIX S4 (blast): SaveOptions no longer encodes "did it actually
flush" as PrimaryObjectId 1u/0u (which read as object guid 0x00000001 in
the K2 event stream). Both host adapters now report the identical shape
(Accepted, objectId 0) — the graphical host never could report this
anyway (LiveCommandBus.Publish has no return channel).
SHOULD-FIX S5 (blast): Replace (the server-seed arrival) now also clears
IsDirty/FirstDirtiedAt — a wholesale re-seed supersedes any pending
batched-but-unflushed local intent (retail's own PlayerModule has no
partial-merge path either), documented at the member.
SHOULD-FIX S6 (blast): a cross-check theory asserting CharacterOptionTable's
masks equal PlayerDescriptionParser.CharacterOptions1/2's independently
(the write path vs the read path TurbineChatMembershipGate/
RuntimeSettingsController consume) — guards the exact CH3 failure class.
Also fixed a real allocation regression found while landing MUST-FIX 1:
the naive per-tick flush closure would have allocated on EVERY
LiveSessionController.Tick() call regardless of dirty state, which broke
the K4 headless 30-session resource-envelope gate. GameRuntime.
FlushCharacterOptions now pre-checks Options.IsDirty (itself retail-
faithful — CPlayerModule::UseTime opens with the identical m_bDirty byte
compare) before allocating the flush closure, so the allocation only
happens on the rare tick that might actually flush.
Dispositions on findings not changed this round:
- Mechanism NOTE 6 / not independently re-flagged: a re-entrant MarkDirty
from inside a flush callback can still be erased by the trailing
"_isDirty = false" — pre-existing, unchanged by the S2 lock restructure
(same outcome whether the callback runs inside or outside the lock),
not reachable from any current caller, not a one-liner to close
correctly (needs a per-dirty-period generation token). Left as documented
in the review; worth closing before the Options panel ever flushes from
inside a change handler.
- Mechanism NOTE 9, blast N2/N3/N4/N5/N6/N8: informational or require
touching files this round doesn't otherwise edit (SocialActions.cs,
CharacterOptionsBlobSource.cs, GameRuntimeContractTests.cs) — left per
the "one-liner in a file already being edited" instruction.
Register: TS-71 retired (both remaining SetCharacterOptions flush
triggers now production-wired); TS-73 filed (the two unmodeled OnChanged
presentation-binding cases, pre-anchored to OP4).
Quality bar: Release build green; full solution suite 12,853 passed / 4
skipped / 0 failed (baseline 12,770/4/0 post-OP2 — 83 new tests added,
zero regressions).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1115 lines
42 KiB
C#
1115 lines
42 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);
|
|
}
|
|
|
|
// ── 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);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
}
|