451 lines
16 KiB
C#
451 lines
16 KiB
C#
using AcDream.Core.Spells;
|
|
using AcDream.Core.Player;
|
|
using AcDream.Runtime.Gameplay;
|
|
|
|
namespace AcDream.Runtime.Tests.Gameplay;
|
|
|
|
public sealed class RuntimeCharacterStateTests
|
|
{
|
|
[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 ResetSession_ClearsBurdenStaminaAndSkillBase()
|
|
{
|
|
using var state = new RuntimeCharacterState();
|
|
state.UpdateMovementSkillBase(runSkillBase: 200, jumpSkillBase: 100);
|
|
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
|
|
// 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);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|