diff --git a/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs b/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs index ec61de85..6b348c16 100644 --- a/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs +++ b/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs @@ -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 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); diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index 462757d8..77bffd39 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -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), diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index 63c666ba..ae496e16 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -85,8 +85,7 @@ internal sealed record SessionPlayerDependencies( CombatState Combat, CombatFeedbackSlot CombatFeedback, RuntimeCommunicationState Communication, - LocalPlayerState LocalPlayer, - Spellbook Spellbook, + RuntimeCharacterState Character, TransferableResourceSlot PortalTunnelFallback, Action 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), diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index 94132d01..feec4b8b 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -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: (_, _) => { }), diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 63fe9aa9..ddb04c2e 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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; /// Persisted hotbar shortcuts from the last PlayerDescription (D.5.1 toolbar source). public IReadOnlyList 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( _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, diff --git a/src/AcDream.App/Rendering/GameWindowLifetime.cs b/src/AcDream.App/Rendering/GameWindowLifetime.cs index aca51946..90a72eeb 100644 --- a/src/AcDream.App/Rendering/GameWindowLifetime.cs +++ b/src/AcDream.App/Rendering/GameWindowLifetime.cs @@ -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), diff --git a/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs b/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs new file mode 100644 index 00000000..c0e54522 --- /dev/null +++ b/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs @@ -0,0 +1,85 @@ +using AcDream.Core.Player; +using AcDream.Core.Spells; + +namespace AcDream.Runtime.Gameplay; + +/// +/// 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. +/// +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; + + /// + /// Installs immutable DAT metadata without transferring its ownership to + /// Runtime. The content host may install one table after portal.dat opens. + /// + 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(); + } + + /// + /// 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. + /// + public void ResetSession() + { + ObjectDisposedException.ThrowIf(_disposed, this); + List? 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? failures) + { + try + { + action(); + } + catch (Exception error) + { + (failures ??= []).Add(error); + } + } +} diff --git a/src/AcDream.Runtime/Gameplay/RuntimeInventoryState.cs b/src/AcDream.Runtime/Gameplay/RuntimeInventoryState.cs index 374fe2a2..fe4eeb97 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeInventoryState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeInventoryState.cs @@ -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()); } - -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)>()); -} diff --git a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs index 75d73a9b..2259f51c 100644 --- a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs +++ b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs @@ -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 PlayerGuid, Action>? OnShortcuts, Action? OnUseDone, ItemManaState? ItemMana, - Action>? OnDesiredComponents, ExternalContainerState? ExternalContainers, Action? OnAppraisal = null); public sealed record LiveCharacterSessionBindings( CombatState Combat, - Spellbook Spellbook, + RuntimeCharacterState Character, Func, uint>? ResolveSkillFormulaBonus, Action? OnSkillsUpdated, Action? 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(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( 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); } diff --git a/tests/AcDream.App.Tests/Composition/ContentEffectsAudioCompositionTests.cs b/tests/AcDream.App.Tests/Composition/ContentEffectsAudioCompositionTests.cs index 86287148..aaff8615 100644 --- a/tests/AcDream.App.Tests/Composition/ContentEffectsAudioCompositionTests.cs +++ b/tests/AcDream.App.Tests/Composition/ContentEffectsAudioCompositionTests.cs @@ -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, diff --git a/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs b/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs index c52afd8b..2651b51f 100644 --- a/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs +++ b/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs @@ -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!, diff --git a/tests/AcDream.App.Tests/Runtime/RuntimeCharacterOwnershipTests.cs b/tests/AcDream.App.Tests/Runtime/RuntimeCharacterOwnershipTests.cs new file mode 100644 index 00000000..0833982f --- /dev/null +++ b/tests/AcDream.App.Tests/Runtime/RuntimeCharacterOwnershipTests.cs @@ -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."); + } +} diff --git a/tests/AcDream.App.Tests/Runtime/RuntimeInventoryOwnershipTests.cs b/tests/AcDream.App.Tests/Runtime/RuntimeInventoryOwnershipTests.cs index 75524455..d99e1e90 100644 --- a/tests/AcDream.App.Tests/Runtime/RuntimeInventoryOwnershipTests.cs +++ b/tests/AcDream.App.Tests/Runtime/RuntimeInventoryOwnershipTests.cs @@ -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( diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs new file mode 100644 index 00000000..f957df8c --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs @@ -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()); + + state.InstallSpellMetadata(table); + state.InstallSpellMetadata(table); + + Assert.Same(table, state.Spellbook.Metadata); + Assert.Throws( + () => 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( + 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); + } +} diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeInventoryStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeInventoryStateTests.cs index d38abba2..70dfc6fb 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeInventoryStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeInventoryStateTests.cs @@ -13,22 +13,17 @@ public sealed class RuntimeInventoryStateTests using var inventory = new RuntimeInventoryState(entities); IReadOnlyList 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] diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs index 19a8868b..7e4dd0ab 100644 --- a/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs @@ -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,