acdream/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs
Erik 86c0a7e0ee feat(runtime,net): Campaign OP slice OP1 — full character-option table, dirty model, real 0x01A1 blob builder
The retail Options panel (Campaign OP) needs a Runtime-owned option map
covering all 53 PlayerOption ids and the real batched SetCharacterOptions
(0x01A1) blob before any UI can be built on top of it. Today's surface only
modeled 6 ListenTo*Chat ids and the 0x01A1 builder was a malformed 16-byte
stub (deleted at Campaign CH slice CH3, docs/research/2026-08-09-chat-side-
channels-vs-ace.md).

- CharacterOptionTable.cs: the ONE typed table, PlayerOption id (0x00..0x34)
  -> (Options1/Options2 word, mask, IsAutoSave, ClientDefault), transcribed
  from acclient.h's verbatim CharacterOption/CharacterOptions2/PlayerOption
  enums and byte-verified against IsAutoSaveOption @0x0059A600 (the 21-id
  auto-save table) and GetDefaultOptionValue @0x005D2A30 (the Defaults-
  button table). Reconstructing CharacterOptions1/2 defaults from the
  ClientDefault column independently reproduces 0x50C4A54A / 0x00008700,
  cross-confirming the id-mask mapping. CharacterOptionId (SocialActions.cs)
  widened from 6 to all 53 ids to match.
- RuntimeCharacterOptionsState: SetOptionBit now resolves through the full
  table (was a 6-case switch). New TrySetOption is the ONE shared local-
  write-then-send/dirty seam — mirrors CPlayerModule::OnChanged exactly:
  write the bit locally first, then either send 0x0005 immediately (auto-
  save ids) or MarkDirty for the batched blob, no-op on an unchanged value
  (retail's own early-return) or an unmodeled id. New dirty model (IsDirty/
  FirstDirtiedAt/MarkDirty/TryFlush/TryFlushIfAutoSaveDue) uses an injected
  TimeProvider so it's fully unit-testable without a live clock.
- Both IRuntimeCharacterCommands.SetSingleOption adapters (Direct + Current)
  now route through TrySetOption instead of duplicating the write; this
  fixes the headless local-write gap the OP1 research flagged (the direct
  adapter previously sent the wire message without writing the bit first,
  same class of bug CH4 fixed for the graphical host). Both also reject an
  id outside the table instead of silently accepting it. LiveSessionRuntime
  Factory's SendSingleCharacterOption closure now delegates to the same
  seam instead of duplicating write-then-send inline.
- New IRuntimeCharacterCommands.SaveOptions(generation) — the explicit
  blob-flush verb (retail's SaveToServer(force: 0)) — wired end-to-end in
  both adapters, including a new SaveCharacterOptionsRuntimeCmd on the
  graphical router.
- SocialActions.BuildSetCharacterOptions + WorldSession.SendSetCharacterOptions:
  the real PlayerModule::Pack body per the wire research's field-by-field
  layout — header always 0x460 OR'd with 0x001/0x008 when shortcuts/desired
  comps are non-empty, favorite spells always 8 lists, never sets 0x100 or
  0x200. Echoes last-parsed shortcuts/favorites/desired-comps/spellbook
  filters (via new CharacterOptionsBlobSource) instead of zeroing them.
  Conformance: a hand-computed golden byte vector (not generated by the
  builder under test — the CH3 builder died of tests that pinned a wrong
  shape and looked green) plus a round-trip through PlayerDescriptionParser.

Contract deviation: the 480 s auto-save timer and the flush-before-logout
trigger are implemented as fully-tested pure state-machine logic
(TryFlushIfAutoSaveDue) but are NOT wired into either host's live per-frame
loop or graceful-shutdown sequence in this slice — only the explicit
SaveOptions verb is production-wired. Wiring the timer touches App's
UpdateFrameOrchestrator graph and Headless's tick loop (outside this
slice's Runtime/wire-layer scope); wiring logout risks the already-fragile
graceful-shutdown sequence CLAUDE.md flags. Filed as TS-71 per the plan's
own escape valve ("target: not deferred" with a register row if deferred).
Also filed: AP-193 (the 0x34 HearPKDeathMessages id/mask is ACE-sourced,
unverifiable against the 2013 binary) and AP-194 (GetDefaultOptionValue's
table disagrees with the constructor default for ConfirmVolatileRareUse/
ShowHelm/ShowCloak — retail's own quirk, reproduced not fixed).

Tests: table completeness x53, auto-save/client-default split pinned
id-by-id against the byte-verified tables, unknown/reserved-id rejection
(0x35/0x36 landmines), local-write-then-send on both adapters + the router,
the dirty/flush state machine, SaveOptions, and the wire golden vector +
PlayerDescriptionParser round-trip. Full Release suite: 12,745 passed / 4
skipped / 0 failed (baseline 12,611/4/0 — slice adds 134 passing tests,
zero regressions).

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

795 lines
29 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: () => sent.Add(
((uint)CharacterOptionId.ListenToGeneralChat, true)));
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: () => sent.Add(((uint)CharacterOptionId.AutoTarget, false)));
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: () => sent.Add(((uint)CharacterOptionId.AutoTarget, true)));
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);
}
[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);
}
[Fact]
public void TryFlush_NoOpWhenClean_FlushesAndClearsWhenDirty()
{
var options = new RuntimeCharacterOptionsState();
int cleanFlushes = 0;
Assert.False(options.TryFlush(() => cleanFlushes++));
Assert.Equal(0, cleanFlushes);
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.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);
}
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);
}
}