Ports retail's SendTurbineChat (@0x0057db10) local pre-send membership gate so Roleplay/Society/Olthoi stop silently swallowing outbound chat: a new TurbineChatMembershipGate checks Turbine availability and the player's own Hear*Chat option before sending, raising "Turbine chat is not available." or the 0x0551 YouAreNotListeningTo_Channel refusal through the CH2 AddText chokepoint instead. Wired into both the graphical (LiveSessionCommandRouter) and headless (DirectGameRuntimeCommandAdapter) send paths so they can't diverge. Retracts the 26-day-old false "ACE doesn't run a TurbineChat server" claim from ISSUES.md, the roadmap, and project_chat_pipeline.md — ACE's TurbineChat implementation is complete and on by default; the real bug was treating Hear*Chat as a display filter instead of room membership. Also: implements SetSingleCharacterOption (0x0005), the only wire message that actually joins/leaves a Turbine room, and wires the five Settings Chat toggles to it (publish on Save, changed bits only) plus seeds ChatSettings from the server's own CharacterOptions2 on every PlayerDescription. Fixes the legacy-channel double-print (Fellow/Vassals/Patron/Monarch/CoVassals skip the local echo now that ChatChannelInfo.IsSelfEchoChannel is finally consulted). Routes /a to Turbine unconditionally (retail's @a never falls back to the legacy bitflag) and adds /ab for the legacy AllegianceBroadcast verb retail actually has. Surfaces a nonzero TurbineChat ack HResult instead of discarding it silently. Deletes the malformed, callerless SetCharacterOptions (0x01A1) and AddChannel/RemoveChannel (0x0145/0x0146) builders. Files every AC-specific algorithm change cites the named retail decomp (SendTurbineChat 0x0057db10, StartupTurbineChatSystem 0x0057EFB0, GameActionSetSingleCharacterOption) plus ACE/holtburger cross-checks. Register rows AP-181 (no client-side spam throttle) and UN-9 (an incidentally-discovered CharacterOptions1.Default literal mismatch, not investigated further) filed per the divergence-register rule. 11,957 passed / 4 skipped / 0 failed (full Release suite, up from the 11,916/4/0 baseline). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
579 lines
21 KiB
C#
579 lines
21 KiB
C#
using AcDream.Core.Items;
|
|
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);
|
|
}
|
|
|
|
// ── 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);
|
|
}
|
|
}
|