acdream/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs
Erik dcb61efb5a refactor(runtime): expose canonical gameplay state
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>
2026-07-26 09:12:30 +02:00

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);
}
}