Move character options and movement skills into the Runtime-owned character graph, expose borrowed inventory, character, and social views, and route retained UI state commands through generation-gated typed Runtime contracts. Preserve the existing synchronous wire path while deleting the App-owned option and skill mirrors and extending normalized parity checkpoints. Co-authored-by: Codex <codex@openai.com>
257 lines
8.5 KiB
C#
257 lines
8.5 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 DisposalRetriesObserverFailuresAndClearsBothOwners()
|
|
{
|
|
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.False(state.IsDisposed);
|
|
Assert.False(state.Spellbook.Knows(7u));
|
|
Assert.Null(state.LocalPlayer.Get(
|
|
AcDream.Core.Player.LocalPlayerState.VitalKind.Health));
|
|
|
|
state.Dispose();
|
|
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);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
}
|