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

@ -12,6 +12,7 @@ using AcDream.Core.Physics;
using AcDream.Core.Rendering;
using AcDream.Core.Spells;
using AcDream.Core.Vfx;
using AcDream.Runtime.Gameplay;
using DatReaderWriter;
using Silk.NET.Input;
using Silk.NET.OpenGL;
@ -48,7 +49,7 @@ internal sealed record ContentEffectsAudioDependencies(
ResidencyBudgetOptions ResidencyBudgets,
PhysicsDataCache PhysicsDataCache,
bool DumpMotionEnabled,
Spellbook SpellBook,
RuntimeCharacterState Character,
AnimationHookRouter HookRouter,
EntityEffectPoseRegistry EffectPoses,
DeferredEntityEffectAdvanceSource EntityEffectAdvance,
@ -85,7 +86,9 @@ internal interface IContentEffectsAudioCompositionFactory
IDatReaderWriter dats,
Action<string> diagnostic);
MagicCatalog LoadMagicCatalog(IDatReaderWriter dats);
void InstallSpellMetadata(Spellbook spellBook, MagicCatalog catalog);
void InstallSpellMetadata(
RuntimeCharacterState character,
MagicCatalog catalog);
int GetSpellCount(MagicCatalog catalog);
IAnimationLoader CreateAnimationLoader(
IDatReaderWriter dats,
@ -143,8 +146,10 @@ internal sealed class RetailContentEffectsAudioCompositionFactory
public MagicCatalog LoadMagicCatalog(IDatReaderWriter dats) =>
MagicCatalog.Load(dats);
public void InstallSpellMetadata(Spellbook spellBook, MagicCatalog catalog) =>
spellBook.InstallMetadata(catalog.SpellTable);
public void InstallSpellMetadata(
RuntimeCharacterState character,
MagicCatalog catalog) =>
character.InstallSpellMetadata(catalog.SpellTable);
public int GetSpellCount(MagicCatalog catalog) => catalog.SpellTable.Count;
@ -326,7 +331,7 @@ internal sealed class ContentEffectsAudioCompositionPhase :
MagicCatalog magic = _factory.LoadMagicCatalog(dats);
_publication.PublishMagicCatalog(magic);
Fault(ContentEffectsAudioCompositionPoint.MagicCatalogPublished);
_factory.InstallSpellMetadata(_dependencies.SpellBook, magic);
_factory.InstallSpellMetadata(_dependencies.Character, magic);
_dependencies.Log(
$"spells: loaded {_factory.GetSpellCount(magic)} entries from portal.dat");
Fault(ContentEffectsAudioCompositionPoint.SpellMetadataInstalled);

View file

@ -48,9 +48,8 @@ internal sealed record InteractionRetainedUiDependencies(
SelectionState Selection,
RuntimeInventoryState Inventory,
MagicCatalog MagicCatalog,
Spellbook Spellbook,
RuntimeCharacterState Character,
RuntimeCommunicationState Communication,
LocalPlayerState LocalPlayer,
StackSplitQuantityState StackSplitQuantity,
BufferedUiRegistry? UiRegistry,
LiveCombatModeCommandSlot CombatModeCommands,
@ -350,7 +349,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
try
{
VitalsVM vitals = d.ExistingVitals
?? new VitalsVM(d.Combat, d.LocalPlayer);
?? new VitalsVM(d.Combat, d.Character.LocalPlayer);
UiHost host = lease.AcquireHost(
() => new UiHost(
d.Gl,
@ -372,7 +371,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
var characterSheet = new CharacterSheetProvider(
d.Inventory.Objects,
d.LocalPlayer,
d.Character.LocalPlayer,
playerGuid: () => d.PlayerIdentity.ServerGuid,
activeToonName: () => d.Settings.ActiveToonKey,
fallbackSheet: Studio.SampleData.SampleCharacter,
@ -389,7 +388,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
MagicRuntime magic = MagicRuntime.Create(
d.MagicCatalog,
d.Spellbook,
d.Character.Spellbook,
d.Inventory.Objects,
selectedObject: () => d.Selection.SelectedObjectId,
localPlayerId: () => d.PlayerIdentity.ServerGuid,
@ -421,7 +420,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
uint skillId = SkillId(school);
if (skillId != 0u)
return d.LocalPlayer.GetSkill(skillId)?.CurrentLevel ?? 0u;
return d.Character.LocalPlayer.GetSkill(skillId)?.CurrentLevel ?? 0u;
uint highest = 0u;
foreach (MagicSchool candidate in new[]
@ -435,7 +434,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
{
highest = Math.Max(
highest,
d.LocalPlayer.GetSkill(SkillId(candidate))?.CurrentLevel ?? 0u);
d.Character.LocalPlayer.GetSkill(
SkillId(candidate))?.CurrentLevel ?? 0u);
}
return highest;
}
@ -527,7 +527,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
() => d.Settings.Gameplay,
d.Settings.SetCombatGameplay),
Magic: new MagicRuntimeBindings(
d.Spellbook,
d.Character.Spellbook,
magic.Casting,
d.Inventory.Objects,
() => d.PlayerIdentity.ServerGuid,
@ -569,10 +569,10 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
late.Selection.ResolveVividTargetInfo,
late.SelectionCamera.UiSnapshot),
Indicators: new IndicatorRuntimeBindings(
d.Spellbook,
d.Character.Spellbook,
d.Inventory.Objects,
() => d.PlayerIdentity.ServerGuid,
() => d.LocalPlayer.GetAttribute(
() => d.Character.LocalPlayer.GetAttribute(
LocalPlayerState.AttributeKind.Strength)
is { } strength ? (int?)strength.Current : null,
() => late.Session.LinkStatus,
@ -614,7 +614,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
() => d.PlayerIdentity.ServerGuid,
iconComposer.GetIcon,
iconComposer.GetDragIcon,
() => d.LocalPlayer.GetAttribute(
() => d.Character.LocalPlayer.GetAttribute(
LocalPlayerState.AttributeKind.Strength)
is { } strength ? (int?)strength.Current : null,
guid => late.Session.CurrentSession?.SendUse(guid),

View file

@ -85,8 +85,7 @@ internal sealed record SessionPlayerDependencies(
CombatState Combat,
CombatFeedbackSlot CombatFeedback,
RuntimeCommunicationState Communication,
LocalPlayerState LocalPlayer,
Spellbook Spellbook,
RuntimeCharacterState Character,
TransferableResourceSlot<PortalTunnelPresentation> PortalTunnelFallback,
Action<string> Log);
@ -802,8 +801,7 @@ internal sealed class SessionPlayerCompositionPhase
d.CharacterOptions),
new LiveSessionDomainRuntime(
d.EntityObjects,
d.LocalPlayer,
d.Spellbook,
d.Character,
d.Combat,
d.Inventory,
d.Communication),

View file

@ -42,8 +42,7 @@ internal sealed record LiveSessionPlayerRuntime(
internal sealed record LiveSessionDomainRuntime(
RuntimeEntityObjectLifetime EntityObjects,
LocalPlayerState LocalPlayer,
Spellbook Spellbook,
RuntimeCharacterState Character,
CombatState Combat,
RuntimeInventoryState Inventory,
RuntimeCommunicationState Communication);
@ -170,12 +169,12 @@ internal sealed class LiveSessionRuntimeFactory
InventoryTransactions = _domain.Inventory.ResetTransactions,
SelectionPresentation = _world.SelectionScene.Reset,
ObjectTable = _domain.EntityObjects.ClearObjects,
Spellbook = _domain.Spellbook.Clear,
Spellbook = _domain.Character.ResetSpellbook,
MagicRuntime = () => _ui.Magic?.Reset(),
CombatAttack = _interaction.CombatAttack.ResetSession,
CombatState = _domain.Combat.Clear,
ItemMana = _domain.Inventory.ResetItemMana,
LocalPlayer = _domain.LocalPlayer.Clear,
LocalPlayer = _domain.Character.ResetLocalPlayer,
Friends = _domain.Communication.ResetFriends,
Squelch = _domain.Communication.ResetSquelch,
TurbineChat = _domain.Communication.ResetNegotiatedChannels,
@ -256,7 +255,6 @@ internal sealed class LiveSessionRuntimeFactory
private LiveInventorySessionBindings CreateInventoryBindings() => new(
_domain.Inventory.Objects,
_domain.LocalPlayer,
PlayerGuid: () => _player.Identity.ServerGuid,
OnShortcuts: _domain.Inventory.Shortcuts.Replace,
OnUseDone: error =>
@ -265,7 +263,6 @@ internal sealed class LiveSessionRuntimeFactory
_domain.Inventory.Transactions.CompleteUse(error);
},
_domain.Inventory.ItemMana,
OnDesiredComponents: _domain.Inventory.DesiredComponents.Replace,
ExternalContainers: _domain.Inventory.ExternalContainers,
OnAppraisal: appraisal =>
{
@ -281,7 +278,7 @@ internal sealed class LiveSessionRuntimeFactory
var skillCreditResolver = new LiveSkillCreditResolver(skillTable);
return new LiveCharacterSessionBindings(
_domain.Combat,
_domain.Spellbook,
_domain.Character,
ResolveSkillFormulaBonus: skillCreditResolver.Resolve,
OnSkillsUpdated: (runSkill, jumpSkill) =>
{
@ -337,7 +334,7 @@ internal sealed class LiveSessionRuntimeFactory
?? "unknown",
CurrentPosition: () => _player.Controller.Controller?.CellPosition,
LastOutsideCorpsePosition: () =>
_domain.LocalPlayer.GetPosition(0x0Eu),
_domain.Character.LocalPlayer.GetPosition(0x0Eu),
ShowConfirmation: (message, completed) =>
_ui.RetailUi?.ShowConfirmation(message, completed),
Suicide: session.SendSuicide,
@ -372,7 +369,6 @@ internal sealed class LiveSessionRuntimeFactory
ClearDesiredComponents: () =>
{
session.SendClearDesiredComponents();
_domain.Inventory.DesiredComponents.Clear();
},
HasOpenVendor: () => false,
FillComponentBuyList: (_, _) => { }),

View file

@ -337,14 +337,14 @@ public sealed class GameWindow :
public readonly AcDream.Core.Combat.CombatState Combat = new();
private readonly RuntimeEntityObjectLifetime _runtimeEntityObjects = new();
private readonly RuntimeInventoryState _runtimeInventory;
private readonly RuntimeCharacterState _runtimeCharacter;
public AcDream.Core.Items.ItemManaState ItemMana =>
_runtimeInventory.ItemMana;
public IReadOnlyList<(uint Id, uint Amount)> DesiredComponents =>
_runtimeInventory.DesiredComponents.Items;
// Retail resolves learned IDs through portal.dat's CSpellTable. MagicCatalog
// installs that immutable table once DatCollection opens in OnLoad.
public AcDream.Core.Spells.SpellTable SpellTable => SpellBook.Metadata;
public readonly AcDream.Core.Spells.Spellbook SpellBook = null!;
public AcDream.Core.Spells.Spellbook SpellBook =>
_runtimeCharacter.Spellbook;
/// <summary>Persisted hotbar shortcuts from the last PlayerDescription (D.5.1 toolbar source).</summary>
public IReadOnlyList<AcDream.Core.Items.ShortcutEntry> Shortcuts =>
_runtimeInventory.Shortcuts.Items;
@ -352,7 +352,8 @@ public sealed class GameWindow :
// PlayerDescription so the Vitals HUD can render those bars.
// Issue #6 — wired to SpellBook so GetMaxApprox folds enchantment
// buffs into the max formula via Spellbook.GetVitalMod.
public readonly AcDream.Core.Player.LocalPlayerState LocalPlayer = null!;
public AcDream.Core.Player.LocalPlayerState LocalPlayer =>
_runtimeCharacter.LocalPlayer;
// Phase D.2a — ImGui devtools UI overlay. Null unless ACDREAM_DEVTOOLS=1.
// See docs/plans/2026-04-24-ui-framework.md for the staged UI strategy.
@ -562,6 +563,7 @@ public sealed class GameWindow :
{
_options = options ?? throw new System.ArgumentNullException(nameof(options));
_runtimeInventory = new RuntimeInventoryState(_runtimeEntityObjects);
_runtimeCharacter = new RuntimeCharacterState();
var alphaScratchBudgets =
AcDream.App.Rendering.Residency.AlphaScratchBudgetProfile.Create(
_options.ResidencyBudgets.AlphaScratchBytes);
@ -591,8 +593,6 @@ public sealed class GameWindow :
_uiRegistry = uiRegistry;
_animatedEntities = new LiveEntityAnimationRuntimeView<LiveEntityAnimationState>(
_liveEntityRuntimeSlot);
SpellBook = new AcDream.Core.Spells.Spellbook();
LocalPlayer = new AcDream.Core.Player.LocalPlayerState(SpellBook);
// #184 Slice 2a: the extracted per-remote DR tick. Its stateful
// setup/motion dependency crosses the fail-fast construction bridge;
// the server-velocity animation cycle is stateless shared policy.
@ -1149,7 +1149,7 @@ public sealed class GameWindow :
_options.ResidencyBudgets,
_physicsDataCache,
_animationDiagnostics.DumpMotionEnabled,
SpellBook,
_runtimeCharacter,
_hookRouter,
_effectPoses,
_entityEffectAdvance,
@ -1261,9 +1261,8 @@ public sealed class GameWindow :
_selection,
_runtimeInventory,
contentEffectsAudio.MagicCatalog,
SpellBook,
_runtimeCharacter,
_runtimeCommunication,
LocalPlayer,
_stackSplitQuantity,
_uiRegistry,
_liveCombatModeCommands,
@ -1405,8 +1404,7 @@ public sealed class GameWindow :
Combat,
_combatFeedback,
_runtimeCommunication,
LocalPlayer,
SpellBook,
_runtimeCharacter,
_portalTunnelFallback,
Console.WriteLine),
this).Compose(
@ -1582,6 +1580,7 @@ public sealed class GameWindow :
_liveEntities,
_runtimeEntityObjects,
_runtimeInventory,
_runtimeCharacter,
_runtimeCommunication,
_renderSceneShadow,
_livePresentationBindings,

View file

@ -85,6 +85,7 @@ internal sealed record LiveShutdownRoots(
LiveEntityRuntime? LiveEntities,
RuntimeEntityObjectLifetime EntityObjects,
RuntimeInventoryState Inventory,
RuntimeCharacterState Character,
RuntimeCommunicationState Communication,
RenderSceneShadowRuntime? RenderSceneShadow,
LivePresentationRuntimeBindings? PresentationBindings,
@ -391,6 +392,9 @@ internal static class GameWindowShutdownManifest
]),
new ResourceShutdownStage("runtime entity/object stream",
[
Hard(
"runtime character state",
live.Character.Dispose),
Hard(
"runtime inventory state",
live.Inventory.Dispose),

View file

@ -0,0 +1,85 @@
using AcDream.Core.Player;
using AcDream.Core.Spells;
namespace AcDream.Runtime.Gameplay;
/// <summary>
/// Canonical presentation-independent owner for the local character's magic
/// and player-sheet state. The two objects form one lifetime group because
/// vital maxima read active enchantments from this exact spellbook.
/// </summary>
public sealed class RuntimeCharacterState : IDisposable
{
private bool _disposed;
public RuntimeCharacterState(SpellTable? spellTable = null)
{
Spellbook = new Spellbook(spellTable);
LocalPlayer = new LocalPlayerState(Spellbook);
}
public Spellbook Spellbook { get; }
public LocalPlayerState LocalPlayer { get; }
public bool IsDisposed => _disposed;
/// <summary>
/// Installs immutable DAT metadata without transferring its ownership to
/// Runtime. The content host may install one table after portal.dat opens.
/// </summary>
public void InstallSpellMetadata(SpellTable spellTable)
{
ObjectDisposedException.ThrowIf(_disposed, this);
Spellbook.InstallMetadata(spellTable);
}
public void ResetSpellbook()
{
ObjectDisposedException.ThrowIf(_disposed, this);
Spellbook.Clear();
}
public void ResetLocalPlayer()
{
ObjectDisposedException.ThrowIf(_disposed, this);
LocalPlayer.Clear();
}
/// <summary>
/// Clears both coupled owners while retaining every failed suffix for a
/// retry. State mutation happens before the existing synchronous
/// invalidation callbacks, so retrying is safe and convergent.
/// </summary>
public void ResetSession()
{
ObjectDisposedException.ThrowIf(_disposed, this);
List<Exception>? failures = null;
Try(Spellbook.Clear, ref failures);
Try(LocalPlayer.Clear, ref failures);
if (failures is not null)
{
throw new AggregateException(
"Runtime character state did not converge during reset.",
failures);
}
}
public void Dispose()
{
if (_disposed)
return;
ResetSession();
_disposed = true;
}
private static void Try(Action action, ref List<Exception>? failures)
{
try
{
action();
}
catch (Exception error)
{
(failures ??= []).Add(error);
}
}
}

View file

@ -20,7 +20,6 @@ public sealed class RuntimeInventoryState : IDisposable
ExternalContainers = new ExternalContainerState();
ItemMana = new ItemManaState();
Shortcuts = new ShortcutState();
DesiredComponents = new DesiredComponentState();
Transactions = new InventoryTransactionState(_entityObjects.Objects);
}
@ -28,7 +27,6 @@ public sealed class RuntimeInventoryState : IDisposable
public ExternalContainerState ExternalContainers { get; }
public ItemManaState ItemMana { get; }
public ShortcutState Shortcuts { get; }
public DesiredComponentState DesiredComponents { get; }
public InventoryTransactionState Transactions { get; }
public bool IsDisposed => _disposed;
@ -39,7 +37,6 @@ public sealed class RuntimeInventoryState : IDisposable
public void ResetPlayerSnapshots()
{
Shortcuts.Clear();
DesiredComponents.Clear();
}
public void Dispose()
@ -50,7 +47,6 @@ public sealed class RuntimeInventoryState : IDisposable
Try(() => ExternalContainers.Reset(), ref failures);
Try(ItemMana.Clear, ref failures);
Try(Shortcuts.Clear, ref failures);
Try(DesiredComponents.Clear, ref failures);
Try(Transactions.Dispose, ref failures);
if (failures is not null)
throw new AggregateException(
@ -90,25 +86,3 @@ public sealed class ShortcutState
public void Clear() => Replace(Array.Empty<ShortcutEntry>());
}
public sealed class DesiredComponentState
{
private IReadOnlyList<(uint Id, uint Amount)> _items =
Array.Empty<(uint Id, uint Amount)>();
private long _revision;
public IReadOnlyList<(uint Id, uint Amount)> Items =>
Volatile.Read(ref _items);
public long Revision => Interlocked.Read(ref _revision);
public void Replace(IReadOnlyList<(uint Id, uint Amount)>? items)
{
Volatile.Write(
ref _items,
items ?? Array.Empty<(uint Id, uint Amount)>());
Interlocked.Increment(ref _revision);
}
public void Clear() =>
Replace(Array.Empty<(uint Id, uint Amount)>());
}

View file

@ -6,6 +6,7 @@ using AcDream.Core.Net.Messages;
using AcDream.Core.Player;
using AcDream.Core.Social;
using AcDream.Core.Spells;
using AcDream.Runtime.Gameplay;
namespace AcDream.Runtime.Session;
@ -29,18 +30,16 @@ public sealed record LiveEnvironmentSessionSink(
public sealed record LiveInventorySessionBindings(
ClientObjectTable Objects,
LocalPlayerState LocalPlayer,
Func<uint> PlayerGuid,
Action<IReadOnlyList<ShortcutEntry>>? OnShortcuts,
Action<uint>? OnUseDone,
ItemManaState? ItemMana,
Action<IReadOnlyList<(uint Id, uint Amount)>>? OnDesiredComponents,
ExternalContainerState? ExternalContainers,
Action<AppraiseInfoParser.Parsed>? OnAppraisal = null);
public sealed record LiveCharacterSessionBindings(
CombatState Combat,
Spellbook Spellbook,
RuntimeCharacterState Character,
Func<uint, IReadOnlyDictionary<uint, uint>, uint>? ResolveSkillFormulaBonus,
Action<int, int>? OnSkillsUpdated,
Action<GameEvents.CharacterConfirmationRequest>? OnConfirmationRequest,
@ -114,7 +113,7 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
session,
inventory.Objects,
inventory.PlayerGuid,
inventory.LocalPlayer,
character.Character.LocalPlayer,
IsAccepting));
ConstructionCheckpoint();
_subscriptions.Add(CombatStateWiring.Wire(
@ -149,9 +148,9 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
session.GameEvents,
inventory.Objects,
character.Combat,
character.Spellbook,
character.Character.Spellbook,
social.Chat,
inventory.LocalPlayer,
character.Character.LocalPlayer,
social.TurbineChat,
onSkillsUpdated: character.OnSkillsUpdated,
resolveSkillFormulaBonus: character.ResolveSkillFormulaBonus,
@ -164,7 +163,7 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
onConfirmationDone: character.OnConfirmationDone,
friends: social.Friends,
squelch: social.Squelch,
onDesiredComponents: inventory.OnDesiredComponents,
onDesiredComponents: null,
onCharacterOptions: character.OnCharacterOptions,
clientTime: character.ClientTime,
externalContainers: inventory.ExternalContainers,
@ -202,7 +201,7 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
h => session.TurbineChatReceived -= h,
parsed => RouteTurbineChat(social.Chat, parsed));
Subscribe<PrivateUpdateVital.ParsedFull>(h => session.VitalUpdated += h, h => session.VitalUpdated -= h, vital =>
inventory.LocalPlayer.OnVitalUpdate(
character.Character.LocalPlayer.OnVitalUpdate(
vital.VitalId,
vital.Ranks,
vital.Start,
@ -211,7 +210,9 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
Subscribe<PrivateUpdateVital.ParsedCurrent>(
h => session.VitalCurrentUpdated += h,
h => session.VitalCurrentUpdated -= h,
vital => inventory.LocalPlayer.OnVitalCurrent(vital.VitalId, vital.Current));
vital => character.Character.LocalPlayer.OnVitalCurrent(
vital.VitalId,
vital.Current));
if (Interlocked.CompareExchange(ref _lifecycleState, 2, 1) != 1)
throw new ObjectDisposedException(nameof(LiveSessionEventRouter));
@ -297,10 +298,9 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
ArgumentNullException.ThrowIfNull(environment.EnvironChanged);
ArgumentNullException.ThrowIfNull(environment.ServerTimeUpdated);
ArgumentNullException.ThrowIfNull(inventory.Objects);
ArgumentNullException.ThrowIfNull(inventory.LocalPlayer);
ArgumentNullException.ThrowIfNull(inventory.PlayerGuid);
ArgumentNullException.ThrowIfNull(character.Combat);
ArgumentNullException.ThrowIfNull(character.Spellbook);
ArgumentNullException.ThrowIfNull(character.Character);
ArgumentNullException.ThrowIfNull(social.Chat);
ArgumentNullException.ThrowIfNull(social.TurbineChat);
}

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,