refactor(runtime): own magic and player state

Move the coupled Spellbook and LocalPlayerState into one Runtime-owned character graph, route content, live-session, retained UI, reset, and shutdown through that exact owner, and delete the duplicate desired-component snapshot from inventory state. Preserve synchronous retail update and reset ordering while adding independent-instance and retryable-failure coverage.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-26 08:39:02 +02:00
parent d362172620
commit d02a12ceac
16 changed files with 418 additions and 96 deletions

View file

@ -15,6 +15,7 @@ using AcDream.Core.Physics;
using AcDream.Core.Rendering;
using AcDream.Core.Spells;
using AcDream.Core.Vfx;
using AcDream.Runtime.Gameplay;
using DatReaderWriter.DBObjs;
using Silk.NET.Input;
using Silk.NET.OpenAL;
@ -257,7 +258,7 @@ public sealed class ContentEffectsAudioCompositionTests
ResidencyBudgetOptions.Default,
new PhysicsDataCache(),
false,
new Spellbook(),
new RuntimeCharacterState(),
Router,
Poses,
new DeferredEntityEffectAdvanceSource(),
@ -289,7 +290,11 @@ public sealed class ContentEffectsAudioCompositionTests
throw new InvalidOperationException($"fault at {point}");
});
public void Dispose() => Publication.Dispose();
public void Dispose()
{
Publication.Dispose();
Dependencies.Character.Dispose();
}
}
private sealed class Publication :
@ -386,7 +391,9 @@ public sealed class ContentEffectsAudioCompositionTests
return _preparedAssets;
}
public MagicCatalog LoadMagicCatalog(IDatReaderWriter dats) => _magic;
public void InstallSpellMetadata(Spellbook spellBook, MagicCatalog catalog) { }
public void InstallSpellMetadata(
RuntimeCharacterState character,
MagicCatalog catalog) { }
public int GetSpellCount(MagicCatalog catalog) => 0;
public IAnimationLoader CreateAnimationLoader(
IDatReaderWriter dats,

View file

@ -191,9 +191,8 @@ public sealed class InteractionRetainedUiCompositionTests
Selection: null!,
Inventory: null!,
MagicCatalog: null!,
Spellbook: null!,
Character: null!,
Communication: null!,
LocalPlayer: null!,
StackSplitQuantity: null!,
UiRegistry: null,
CombatModeCommands: null!,

View file

@ -0,0 +1,106 @@
namespace AcDream.App.Tests.Runtime;
public sealed class RuntimeCharacterOwnershipTests
{
[Fact]
public void ProductionConstructsOnlyTheRuntimeCharacterOwner()
{
string source = ReadSource("Rendering", "GameWindow.cs");
Assert.Contains(
"_runtimeCharacter = new RuntimeCharacterState();",
source,
StringComparison.Ordinal);
Assert.DoesNotContain(
"new AcDream.Core.Spells.Spellbook",
source,
StringComparison.Ordinal);
Assert.DoesNotContain(
"new AcDream.Core.Player.LocalPlayerState",
source,
StringComparison.Ordinal);
Assert.Contains(
"SpellBook =>\n _runtimeCharacter.Spellbook;",
Normalize(source),
StringComparison.Ordinal);
Assert.Contains(
"LocalPlayer =>\n _runtimeCharacter.LocalPlayer;",
Normalize(source),
StringComparison.Ordinal);
}
[Fact]
public void SessionRoutingAndShutdownBorrowTheExactRuntimeOwner()
{
string session = ReadSource("Net", "LiveSessionRuntimeFactory.cs");
string router = ReadRuntimeSource(
"Session",
"LiveSessionEventRouter.cs");
string content = ReadSource(
"Composition",
"ContentEffectsAudioComposition.cs");
string ui = ReadSource(
"Composition",
"InteractionRetainedUiComposition.cs");
string inventory = ReadRuntimeSource(
"Gameplay",
"RuntimeInventoryState.cs");
string shutdown = ReadSource("Rendering", "GameWindowLifetime.cs");
Assert.Contains(
"RuntimeCharacterState Character",
session,
StringComparison.Ordinal);
Assert.Contains(
"character.Character.Spellbook",
router,
StringComparison.Ordinal);
Assert.Contains(
"character.Character.LocalPlayer",
router,
StringComparison.Ordinal);
Assert.Contains(
"_factory.InstallSpellMetadata(_dependencies.Character, magic)",
content,
StringComparison.Ordinal);
Assert.Contains(
"d.Character.Spellbook",
ui,
StringComparison.Ordinal);
Assert.Contains(
"d.Character.LocalPlayer",
ui,
StringComparison.Ordinal);
Assert.DoesNotContain(
"DesiredComponentState",
inventory,
StringComparison.Ordinal);
Assert.Contains(
"\"runtime character state\"",
shutdown,
StringComparison.Ordinal);
}
private static string ReadSource(params string[] relative) =>
File.ReadAllText(Path.Combine(
[FindRepositoryRoot(), "src", "AcDream.App", .. relative]));
private static string ReadRuntimeSource(params string[] relative) =>
File.ReadAllText(Path.Combine(
[FindRepositoryRoot(), "src", "AcDream.Runtime", .. relative]));
private static string Normalize(string source) =>
source.Replace("\r\n", "\n", StringComparison.Ordinal);
private static string FindRepositoryRoot()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
if (File.Exists(Path.Combine(current.FullName, "AcDream.slnx")))
return current.FullName;
current = current.Parent;
}
throw new DirectoryNotFoundException("AcDream.slnx was not found.");
}
}

View file

@ -27,6 +27,10 @@ public sealed class RuntimeInventoryOwnershipTests
"new AcDream.Core.Items.ExternalContainerState",
source,
StringComparison.Ordinal);
Assert.DoesNotContain(
"DesiredComponentState",
source,
StringComparison.Ordinal);
}
[Fact]
@ -50,8 +54,8 @@ public sealed class RuntimeInventoryOwnershipTests
"_domain.Inventory.Transactions.CompleteUse(error)",
session,
StringComparison.Ordinal);
Assert.Contains(
"OnDesiredComponents: _domain.Inventory.DesiredComponents.Replace",
Assert.DoesNotContain(
"_domain.Inventory.DesiredComponents",
session,
StringComparison.Ordinal);
AssertAppearsInOrder(

View file

@ -0,0 +1,155 @@
using AcDream.Core.Spells;
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);
}
}

View file

@ -13,22 +13,17 @@ public sealed class RuntimeInventoryStateTests
using var inventory = new RuntimeInventoryState(entities);
IReadOnlyList<ShortcutEntry> shortcuts =
[new ShortcutEntry(2, 0x50000001u, 0u)];
IReadOnlyList<(uint Id, uint Amount)> components =
[(0x68000001u, 12u)];
inventory.ExternalContainers.RequestOpen(0x70000001u);
inventory.ExternalContainers.ApplyViewContents(0x70000001u);
inventory.ItemMana.OnQueryItemManaResponse(0x50000001u, 0.75f, true);
inventory.Shortcuts.Replace(shortcuts);
inventory.DesiredComponents.Replace(components);
Assert.Same(entities.Objects, inventory.Objects);
Assert.Equal(0x70000001u, inventory.ExternalContainers.CurrentContainerId);
Assert.Equal(0.75f, inventory.ItemMana.GetManaPercent(0x50000001u));
Assert.Same(shortcuts, inventory.Shortcuts.Items);
Assert.Same(components, inventory.DesiredComponents.Items);
Assert.Equal(1, inventory.Shortcuts.Revision);
Assert.Equal(1, inventory.DesiredComponents.Revision);
}
[Fact]
@ -40,7 +35,6 @@ public sealed class RuntimeInventoryStateTests
inventory.ExternalContainers.ApplyViewContents(0x70000001u);
inventory.ItemMana.OnQueryItemManaResponse(0x50000001u, 0.75f, true);
inventory.Shortcuts.Replace([new ShortcutEntry(1, 2u, 3u)]);
inventory.DesiredComponents.Replace([(4u, 5u)]);
inventory.Transactions.IncrementBusyCount();
inventory.ResetExternalContainer();
@ -56,7 +50,6 @@ public sealed class RuntimeInventoryStateTests
Assert.Equal(0, inventory.Transactions.BusyCount);
Assert.False(inventory.ItemMana.HasMana(0x50000001u));
Assert.Empty(inventory.Shortcuts.Items);
Assert.Empty(inventory.DesiredComponents.Items);
}
[Fact]
@ -86,7 +79,6 @@ public sealed class RuntimeInventoryStateTests
inventory.ExternalContainers.ApplyViewContents(0x70000001u);
inventory.ItemMana.OnQueryItemManaResponse(0x50000001u, 0.5f, true);
inventory.Shortcuts.Replace([new ShortcutEntry(1, 2u, 3u)]);
inventory.DesiredComponents.Replace([(4u, 5u)]);
inventory.Dispose();
inventory.Dispose();
@ -96,7 +88,6 @@ public sealed class RuntimeInventoryStateTests
Assert.Equal(0u, inventory.ExternalContainers.CurrentContainerId);
Assert.False(inventory.ItemMana.HasMana(0x50000001u));
Assert.Empty(inventory.Shortcuts.Items);
Assert.Empty(inventory.DesiredComponents.Items);
}
[Fact]

View file

@ -8,6 +8,7 @@ using AcDream.Core.Player;
using AcDream.Core.Social;
using AcDream.Core.Spells;
using AcDream.Runtime.Session;
using AcDream.Runtime.Gameplay;
namespace AcDream.Runtime.Tests.Session;
@ -227,18 +228,16 @@ public sealed class LiveSessionEventRouterTests
private static LiveInventorySessionBindings NewInventoryBindings() => new(
new ClientObjectTable(),
new LocalPlayerState(),
PlayerGuid: () => 0x50000001u,
OnShortcuts: null,
OnUseDone: null,
ItemMana: new ItemManaState(),
OnDesiredComponents: null,
ExternalContainers: new ExternalContainerState());
private static LiveCharacterSessionBindings NewCharacterBindings(
CombatState? combat = null) => new(
combat ?? new CombatState(),
new Spellbook(),
new RuntimeCharacterState(),
ResolveSkillFormulaBonus: null,
OnSkillsUpdated: null,
OnConfirmationRequest: null,