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>
1207 lines
46 KiB
C#
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);
|
|
}
|
|
}
|