diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index 77bffd39..964e251a 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -18,6 +18,7 @@ using AcDream.Core.Items; using AcDream.Core.Player; using AcDream.Core.Selection; using AcDream.Core.Spells; +using AcDream.Runtime; using AcDream.Runtime.Gameplay; using AcDream.UI.Abstractions.Input; using AcDream.UI.Abstractions.Panels.Chat; @@ -56,7 +57,6 @@ internal sealed record InteractionRetainedUiDependencies( ILocalPlayerIdentitySource PlayerIdentity, ILocalPlayerControllerSource PlayerController, ILocalPlayerModeSource PlayerMode, - PlayerCharacterOptionsState CharacterOptions, Func SelectionCameraFactory, DeferredRenderFrameDiagnosticsSource FrameDiagnostics, @@ -118,6 +118,7 @@ internal sealed class InteractionUiLateBindings : IDisposable private bool _deactivationStarted; public DeferredLiveSessionUiAuthority Session { get; } = new(); + public DeferredGameRuntimeStateCommands GameRuntime { get; } = new(); public DeferredSelectionUiAuthority Selection { get; } = new(); public DeferredSelectionViewPlaneSource SelectionViewPlane { get; } = new(); public DeferredRadarSnapshotSource Radar { get; } = new(); @@ -176,6 +177,7 @@ internal sealed class InteractionUiLateBindings : IDisposable Radar.Deactivate(); SelectionViewPlane.Deactivate(); Selection.Deactivate(); + GameRuntime.Deactivate(); Session.Deactivate(); InventoryContainer.Deactivate(); for (int i = _lateOwnerBindings.Count - 1; i >= 0; i--) @@ -294,7 +296,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory sendGive: (target, item, amount) => session.CurrentSession?.SendGiveObject(target, item, amount), dragOnPlayerOpensSecureTrade: () => - d.CharacterOptions.DragItemOnPlayerOpensSecureTrade, + d.Character.Options.DragItemOnPlayerOpensSecureTrade, toast: d.Toast, readyForInventoryRequest: () => session.IsInWorld, playerOnGround: () => @@ -375,15 +377,27 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory playerGuid: () => d.PlayerIdentity.ServerGuid, activeToonName: () => d.Settings.ActiveToonKey, fallbackSheet: Studio.SampleData.SampleCharacter, - canSendRaise: () => late.Session.IsInWorld, + canSendRaise: () => late.GameRuntime.IsInWorld, sendRaiseAttribute: (statId, cost) => - late.Session.CurrentSession?.SendRaiseAttribute(statId, cost), + late.GameRuntime.Advance( + RuntimeAdvancementKind.Attribute, + statId, + cost), sendRaiseVital: (statId, cost) => - late.Session.CurrentSession?.SendRaiseVital(statId, cost), + late.GameRuntime.Advance( + RuntimeAdvancementKind.Vital, + statId, + cost), sendRaiseSkill: (statId, cost) => - late.Session.CurrentSession?.SendRaiseSkill(statId, cost), + late.GameRuntime.Advance( + RuntimeAdvancementKind.Skill, + statId, + cost), sendTrainSkill: (statId, credits) => - late.Session.CurrentSession?.SendTrainSkill(statId, credits)); + late.GameRuntime.Advance( + RuntimeAdvancementKind.TrainSkill, + statId, + credits)); checkpoint(InteractionRetainedUiCompositionPoint.CharacterSheetCreated); MagicRuntime magic = MagicRuntime.Create( @@ -543,16 +557,13 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory guid => d.Selection.Select(guid, SelectionChangeSource.Inventory), guid => late.Session.TryUseItem(guid, d.Log), (tab, position, spellId) => - late.Session.CurrentSession?.SendAddSpellFavorite( - spellId, - position, - tab), + late.GameRuntime.AddFavorite(tab, position, spellId), (tab, spellId) => - late.Session.CurrentSession?.SendRemoveSpellFavorite(spellId, tab), - filters => late.Session.CurrentSession?.SendSpellbookFilter(filters), - spellId => late.Session.CurrentSession?.SendRemoveSpell(spellId), + late.GameRuntime.RemoveFavorite(tab, spellId), + filters => late.GameRuntime.SetSpellbookFilter(filters), + spellId => late.GameRuntime.ForgetSpell(spellId), (componentId, amount) => - late.Session.CurrentSession?.SendSetDesiredComponentLevel( + late.GameRuntime.SetDesiredComponent( componentId, amount), d.ClientTime), @@ -589,8 +600,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory d.Inventory.ItemMana, d.CombatModeCommands.Toggle, itemInteraction, - entry => late.Session.CurrentSession?.SendAddShortcut(entry), - index => late.Session.CurrentSession?.SendRemoveShortcut(index), + entry => late.GameRuntime.AddShortcut(entry), + index => late.GameRuntime.RemoveShortcut(index), d.Selection, handler => d.Combat.HealthChanged += handler, handler => d.Combat.HealthChanged -= handler, diff --git a/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs b/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs index dbcb509f..5e5ceb54 100644 --- a/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs +++ b/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs @@ -9,11 +9,186 @@ using AcDream.App.UI.Testing; using AcDream.Core.Items; using AcDream.Core.Net; using AcDream.Core.Net.Messages; +using AcDream.Runtime; using AcDream.UI.Abstractions; using Silk.NET.Windowing; namespace AcDream.App.Composition; +/// +/// Early retained-UI projection over Slice J's later current-runtime seam. +/// Every call captures the view and command owner together and supplies that +/// exact generation, so a displaced session can never receive the command. +/// +internal sealed class DeferredGameRuntimeStateCommands +{ + private readonly object _gate = new(); + private IGameRuntimeView? _view; + private IGameRuntimeCommands? _commands; + private bool _deactivated; + + public bool IsInWorld + { + get + { + lock (_gate) + return !_deactivated + && _view?.Lifecycle.State == RuntimeLifecycleState.InWorld; + } + } + + public IDisposable Bind( + IGameRuntimeView view, + IGameRuntimeCommands commands) + { + ArgumentNullException.ThrowIfNull(view); + ArgumentNullException.ThrowIfNull(commands); + lock (_gate) + { + ObjectDisposedException.ThrowIf(_deactivated, this); + if (_view is not null || _commands is not null) + { + throw new InvalidOperationException( + "The retained-UI game-runtime command seam is already bound."); + } + _view = view; + _commands = commands; + } + return new ExpectedRuntimeBinding(this, view, commands); + } + + public RuntimeCommandResult AddShortcut(ShortcutEntry entry) => + Invoke((commands, generation) => commands.InventoryState.AddShortcut( + generation, + new RuntimeShortcutCommand( + entry.Index, + entry.ObjectId, + entry.SpellId))); + + public RuntimeCommandResult RemoveShortcut(uint index) + { + if (index > int.MaxValue) + return CurrentResult(RuntimeCommandStatus.Rejected); + return Invoke((commands, generation) => + commands.InventoryState.RemoveShortcut( + generation, + (int)index)); + } + + public RuntimeCommandResult AddFavorite( + int tab, + int position, + uint spellId) => + Invoke((commands, generation) => commands.Spellbook.AddFavorite( + generation, + tab, + position, + spellId)); + + public RuntimeCommandResult RemoveFavorite(int tab, uint spellId) => + Invoke((commands, generation) => commands.Spellbook.RemoveFavorite( + generation, + tab, + spellId)); + + public RuntimeCommandResult SetSpellbookFilter(uint filters) => + Invoke((commands, generation) => commands.Spellbook.SetFilter( + generation, + filters)); + + public RuntimeCommandResult ForgetSpell(uint spellId) => + Invoke((commands, generation) => commands.Spellbook.ForgetSpell( + generation, + spellId)); + + public RuntimeCommandResult SetDesiredComponent( + uint componentId, + uint amount) => + Invoke((commands, generation) => + commands.Spellbook.SetDesiredComponent( + generation, + componentId, + amount)); + + public RuntimeCommandResult Advance( + RuntimeAdvancementKind kind, + uint statId, + ulong cost) => + Invoke((commands, generation) => commands.Character.Advance( + generation, + new RuntimeAdvancementCommand(kind, statId, cost))); + + public void Deactivate() + { + lock (_gate) + { + _deactivated = true; + _view = null; + _commands = null; + } + } + + private RuntimeCommandResult Invoke( + Func< + IGameRuntimeCommands, + RuntimeGenerationToken, + RuntimeCommandResult> invoke) + { + IGameRuntimeCommands commands; + RuntimeGenerationToken generation; + lock (_gate) + { + if (_deactivated || _view is null || _commands is null) + { + return new RuntimeCommandResult( + RuntimeCommandStatus.Inactive, + _view?.Generation ?? default); + } + commands = _commands; + generation = _view.Generation; + } + return invoke(commands, generation); + } + + private RuntimeCommandResult CurrentResult(RuntimeCommandStatus status) + { + lock (_gate) + { + return new RuntimeCommandResult( + status, + _view?.Generation ?? default); + } + } + + private void Release( + IGameRuntimeView expectedView, + IGameRuntimeCommands expectedCommands) + { + lock (_gate) + { + if (!ReferenceEquals(_view, expectedView) + || !ReferenceEquals(_commands, expectedCommands)) + { + return; + } + _view = null; + _commands = null; + } + } + + private sealed class ExpectedRuntimeBinding( + DeferredGameRuntimeStateCommands owner, + IGameRuntimeView view, + IGameRuntimeCommands commands) + : IDisposable + { + private DeferredGameRuntimeStateCommands? _owner = owner; + + public void Dispose() => + Interlocked.Exchange(ref _owner, null)?.Release(view, commands); + } +} + /// /// Early UI view over the later live-session owner. A binding lease clears only /// the exact owner it installed, so rollback cannot withdraw a replacement. @@ -394,19 +569,6 @@ internal sealed class DeferredInventoryContainerSource } } -internal sealed class PlayerCharacterOptionsState -{ - public PlayerDescriptionParser.CharacterOptions1 Options { get; set; } = - PlayerDescriptionParser.CharacterOptions1.Default; - - public bool DragItemOnPlayerOpensSecureTrade => - (Options - & PlayerDescriptionParser.CharacterOptions1 - .DragItemOnPlayerOpensSecureTrade) != 0; - - public void Reset() => Options = PlayerDescriptionParser.CharacterOptions1.Default; -} - /// /// Probe scripts are mounted in Phase 5; reveal/resource facts become valid in /// Phase 7. Calls remain inert and diagnostic until that exact owner binds. diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index ae496e16..11562712 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -68,10 +68,8 @@ internal sealed record SessionPlayerDependencies( LiveWorldOriginState WorldOrigin, WorldRenderRangeState RenderRange, LocalPlayerShadowState PlayerShadow, - LocalPlayerSkillState PlayerSkills, ViewportAspectState ViewportAspect, PlayerApproachCompletionState PlayerApproachCompletions, - PlayerCharacterOptionsState CharacterOptions, RuntimeInventoryState Inventory, PointerPositionState PointerPosition, DispatcherMovementInputSource MovementInput, @@ -710,7 +708,7 @@ internal sealed class SessionPlayerCompositionPhase gameplayInput, liveSessionSource, d.MovementDiagnostics, - d.PlayerSkills, + d.Character.MovementSkills, d.ViewportAspect); if (d.SettingsDevTools.DevTools is { } devTools) { @@ -796,9 +794,7 @@ internal sealed class SessionPlayerCompositionPhase new LiveSessionPlayerRuntime( d.PlayerIdentity, d.PlayerController, - d.PlayerSkills, - d.WorldOrigin, - d.CharacterOptions), + d.WorldOrigin), new LiveSessionDomainRuntime( d.EntityObjects, d.Character, @@ -876,6 +872,8 @@ internal sealed class SessionPlayerCompositionPhase d.PlayerIdentity, live.LiveEntities, d.EntityObjects, + d.Inventory, + d.Character, d.Communication, d.PlayerController, worldReveal, @@ -885,6 +883,9 @@ internal sealed class SessionPlayerCompositionPhase gameplayInput, combatCommand); bindings.Adopt("current game runtime adapter", gameRuntime); + bindings.Adopt( + "retained-UI game runtime commands", + interaction.LateBindings.GameRuntime.Bind(gameRuntime, gameRuntime)); var nearbyDiagnostics = new NearbyWorldDiagnosticDumper( new RuntimeNearbyWorldDiagnosticSource( diff --git a/src/AcDream.App/Input/LocalPlayerRuntimeState.cs b/src/AcDream.App/Input/LocalPlayerRuntimeState.cs index 3b9868f3..59adbf2b 100644 --- a/src/AcDream.App/Input/LocalPlayerRuntimeState.cs +++ b/src/AcDream.App/Input/LocalPlayerRuntimeState.cs @@ -140,38 +140,21 @@ internal sealed class LocalPlayerModeState : ILocalPlayerModeSource /// delivery and player-mode construction share this owner so rebuilding the /// local physics controller cannot fall back to stale defaults. /// -internal sealed class LocalPlayerSkillState +internal static class LocalPlayerSkillProjection { - public int RunSkill { get; private set; } = -1; - public int JumpSkill { get; private set; } = -1; - public bool IsComplete => RunSkill >= 0 && JumpSkill >= 0; - - public void Update( - int runSkill, - int jumpSkill, + public static bool ApplyTo( + AcDream.Runtime.Gameplay.RuntimeMovementSkillState skills, PlayerMovementController? controller) { - if (runSkill >= 0) - RunSkill = runSkill; - if (jumpSkill >= 0) - JumpSkill = jumpSkill; - ApplyTo(controller); - } - - public bool ApplyTo(PlayerMovementController? controller) - { - if (controller is null || !IsComplete) + ArgumentNullException.ThrowIfNull(skills); + AcDream.Runtime.Gameplay.RuntimeMovementSkillSnapshot snapshot = + skills.Snapshot; + if (controller is null || !snapshot.IsComplete) return false; - controller.SetCharacterSkills(RunSkill, JumpSkill); + controller.SetCharacterSkills(snapshot.RunSkill, snapshot.JumpSkill); return true; } - - public void ResetSession() - { - RunSkill = -1; - JumpSkill = -1; - } } internal interface IViewportAspectSource diff --git a/src/AcDream.App/Input/PlayerModeController.cs b/src/AcDream.App/Input/PlayerModeController.cs index 45e8b0df..654dc700 100644 --- a/src/AcDream.App/Input/PlayerModeController.cs +++ b/src/AcDream.App/Input/PlayerModeController.cs @@ -9,6 +9,7 @@ using AcDream.Content; using AcDream.Core.Physics; using AcDream.Core.Physics.Motion; using AcDream.Core.World; +using AcDream.Runtime.Gameplay; namespace AcDream.App.Input; @@ -41,7 +42,7 @@ internal sealed class PlayerModeController : private readonly ILocalPlayerTeleportInputLifetime _input; private readonly ILiveInWorldSource _session; private readonly MovementTruthDiagnosticController _movementDiagnostics; - private readonly LocalPlayerSkillState _skills; + private readonly RuntimeMovementSkillState _skills; private readonly IViewportAspectSource _viewport; private PlayerModeAutoEntry? _autoEntry; private IPlayerApproachCompletionSink? _approachLifetime; @@ -67,7 +68,7 @@ internal sealed class PlayerModeController : ILocalPlayerTeleportInputLifetime input, ILiveInWorldSource session, MovementTruthDiagnosticController movementDiagnostics, - LocalPlayerSkillState skills, + RuntimeMovementSkillState skills, IViewportAspectSource viewport) { _mode = mode ?? throw new ArgumentNullException(nameof(mode)); @@ -359,7 +360,7 @@ internal sealed class PlayerModeController : exactMovement.CancelMoveTo(WeenieError.ActionCancelled); }; - if (_skills.ApplyTo(controller)) + if (LocalPlayerSkillProjection.ApplyTo(_skills, controller)) { Console.WriteLine( $"live: {loggingTag} — applied server skills " diff --git a/src/AcDream.App/Net/LiveSessionCommandRouter.cs b/src/AcDream.App/Net/LiveSessionCommandRouter.cs index 14b4256c..278e6f10 100644 --- a/src/AcDream.App/Net/LiveSessionCommandRouter.cs +++ b/src/AcDream.App/Net/LiveSessionCommandRouter.cs @@ -1,5 +1,6 @@ using AcDream.App.UI; using AcDream.Core.Chat; +using AcDream.Core.Items; using AcDream.Core.Net.Messages; using AcDream.Runtime.Session; using AcDream.UI.Abstractions; @@ -15,8 +16,64 @@ internal sealed record LiveSessionCommandBindings( Action SendTell, Action SendChannel, Action SendTurbineChat, + Action AddShortcut, + Action RemoveShortcut, + Action AddFavorite, + Action RemoveFavorite, + Action SetSpellbookFilter, + Action ForgetSpell, + Action SetDesiredComponent, + Action ClearDesiredComponents, + Action RaiseAttribute, + Action RaiseVital, + Action RaiseSkill, + Action TrainSkill, + Action SetCharacterOptions, + Action AddFriend, + Action RemoveFriend, + Action ClearFriends, + Action RequestLegacyFriends, + Action ModifyCharacterSquelch, + Action ModifyAccountSquelch, + Action ModifyGlobalSquelch, Action? Log = null); +internal readonly record struct AddShortcutRuntimeCmd(ShortcutEntry Entry); +internal readonly record struct RemoveShortcutRuntimeCmd(uint Index); +internal readonly record struct AddFavoriteRuntimeCmd( + uint SpellId, + int Position, + int TabIndex); +internal readonly record struct RemoveFavoriteRuntimeCmd( + uint SpellId, + int TabIndex); +internal readonly record struct SetSpellbookFilterRuntimeCmd(uint Filters); +internal readonly record struct ForgetSpellRuntimeCmd(uint SpellId); +internal readonly record struct SetDesiredComponentRuntimeCmd( + uint ComponentId, + uint Amount); +internal readonly record struct ClearDesiredComponentsRuntimeCmd; +internal readonly record struct RaiseAttributeRuntimeCmd(uint StatId, ulong Cost); +internal readonly record struct RaiseVitalRuntimeCmd(uint StatId, ulong Cost); +internal readonly record struct RaiseSkillRuntimeCmd(uint StatId, ulong Cost); +internal readonly record struct TrainSkillRuntimeCmd(uint StatId, uint Cost); +internal readonly record struct SetCharacterOptionsRuntimeCmd(uint Options); +internal readonly record struct AddFriendRuntimeCmd(string Name); +internal readonly record struct RemoveFriendRuntimeCmd(uint CharacterId); +internal readonly record struct ClearFriendsRuntimeCmd; +internal readonly record struct RequestLegacyFriendsRuntimeCmd; +internal readonly record struct ModifyCharacterSquelchRuntimeCmd( + bool Add, + uint CharacterId, + string Name, + uint MessageType); +internal readonly record struct ModifyAccountSquelchRuntimeCmd( + bool Add, + string Name); +internal readonly record struct ModifyGlobalSquelchRuntimeCmd( + bool Add, + uint MessageType); + /// /// Owns the command surface for one exact live-session generation. The router /// itself is the published bus, so a retained reference becomes inert before @@ -52,6 +109,68 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting SendIfActive(() => bindings.SendTalk(command.Text)); }); commands.Register(command => RouteChat(bindings, command)); + commands.Register( + command => SendIfActive(() => bindings.AddShortcut(command.Entry))); + commands.Register( + command => SendIfActive(() => bindings.RemoveShortcut(command.Index))); + commands.Register( + command => SendIfActive(() => bindings.AddFavorite( + command.SpellId, + command.Position, + command.TabIndex))); + commands.Register( + command => SendIfActive(() => bindings.RemoveFavorite( + command.SpellId, + command.TabIndex))); + commands.Register( + command => SendIfActive(() => + bindings.SetSpellbookFilter(command.Filters))); + commands.Register( + command => SendIfActive(() => bindings.ForgetSpell(command.SpellId))); + commands.Register( + command => SendIfActive(() => bindings.SetDesiredComponent( + command.ComponentId, + command.Amount))); + commands.Register( + _ => SendIfActive(bindings.ClearDesiredComponents)); + commands.Register( + command => SendIfActive(() => + bindings.RaiseAttribute(command.StatId, command.Cost))); + commands.Register( + command => SendIfActive(() => + bindings.RaiseVital(command.StatId, command.Cost))); + commands.Register( + command => SendIfActive(() => + bindings.RaiseSkill(command.StatId, command.Cost))); + commands.Register( + command => SendIfActive(() => + bindings.TrainSkill(command.StatId, command.Cost))); + commands.Register( + command => SendIfActive(() => + bindings.SetCharacterOptions(command.Options))); + commands.Register( + command => SendIfActive(() => bindings.AddFriend(command.Name))); + commands.Register( + command => SendIfActive(() => + bindings.RemoveFriend(command.CharacterId))); + commands.Register( + _ => SendIfActive(bindings.ClearFriends)); + commands.Register( + _ => SendIfActive(bindings.RequestLegacyFriends)); + commands.Register( + command => SendIfActive(() => bindings.ModifyCharacterSquelch( + command.Add, + command.CharacterId, + command.Name, + command.MessageType))); + commands.Register( + command => SendIfActive(() => bindings.ModifyAccountSquelch( + command.Add, + command.Name))); + commands.Register( + command => SendIfActive(() => bindings.ModifyGlobalSquelch( + command.Add, + command.MessageType))); _commands = commands; } diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index feec4b8b..caca5cc3 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -36,9 +36,7 @@ namespace AcDream.App.Net; internal sealed record LiveSessionPlayerRuntime( LocalPlayerIdentityState Identity, LocalPlayerControllerSlot Controller, - LocalPlayerSkillState Skills, - LiveWorldOriginState WorldOrigin, - PlayerCharacterOptionsState CharacterOptions); + LiveWorldOriginState WorldOrigin); internal sealed record LiveSessionDomainRuntime( RuntimeEntityObjectLifetime EntityObjects, @@ -215,8 +213,8 @@ internal sealed class LiveSessionRuntimeFactory _domain.Communication.ResetChatIdentity(); EntityVanishProbe.PlayerGuid = 0u; _interaction.Settings.ResetActiveCharacterKey(); - _player.CharacterOptions.Reset(); - _player.Skills.ResetSession(); + _domain.Character.Options.ResetSession(); + _domain.Character.MovementSkills.ResetSession(); _world.NetworkUpdates.ResetSessionState(); _world.Hydration.ResetSessionState(); _domain.Inventory.ResetPlayerSnapshots(); @@ -282,27 +280,22 @@ internal sealed class LiveSessionRuntimeFactory ResolveSkillFormulaBonus: skillCreditResolver.Resolve, OnSkillsUpdated: (runSkill, jumpSkill) => { - _player.Skills.Update( - runSkill, - jumpSkill, - _player.Controller.Controller); - if (_player.Skills.IsComplete - && _player.Controller.Controller is not null) + if (LocalPlayerSkillProjection.ApplyTo( + _domain.Character.MovementSkills, + _player.Controller.Controller)) { + RuntimeMovementSkillSnapshot snapshot = + _domain.Character.MovementSkills.Snapshot; _log( $"player: applied server skills " + - $"run={_player.Skills.RunSkill} " + - $"jump={_player.Skills.JumpSkill}"); + $"run={snapshot.RunSkill} " + + $"jump={snapshot.JumpSkill}"); } }, OnConfirmationRequest: request => _ui.RetailUi?.HandleConfirmationRequest(request), OnConfirmationDone: done => _ui.RetailUi?.HandleConfirmationDone(done), - OnCharacterOptions: (options1, _) => - _player.CharacterOptions.Options = - (Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1) - options1, ClientTime: ClientTimerNow); } @@ -391,6 +384,26 @@ internal sealed class LiveSessionRuntimeFactory senderGuid, text, cookie), + AddShortcut: session.SendAddShortcut, + RemoveShortcut: session.SendRemoveShortcut, + AddFavorite: session.SendAddSpellFavorite, + RemoveFavorite: session.SendRemoveSpellFavorite, + SetSpellbookFilter: session.SendSpellbookFilter, + ForgetSpell: session.SendRemoveSpell, + SetDesiredComponent: session.SendSetDesiredComponentLevel, + ClearDesiredComponents: session.SendClearDesiredComponents, + RaiseAttribute: session.SendRaiseAttribute, + RaiseVital: session.SendRaiseVital, + RaiseSkill: session.SendRaiseSkill, + TrainSkill: session.SendTrainSkill, + SetCharacterOptions: session.SendSetCharacterOptions, + AddFriend: session.SendAddFriend, + RemoveFriend: session.SendRemoveFriend, + ClearFriends: session.SendClearFriends, + RequestLegacyFriends: session.SendLegacyFriendsListRequest, + ModifyCharacterSquelch: session.SendModifyCharacterSquelch, + ModifyAccountSquelch: session.SendModifyAccountSquelch, + ModifyGlobalSquelch: session.SendModifyGlobalSquelch, Log: _log); private static double ClientTimerNow() => diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index ddb04c2e..b564b1f8 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -440,12 +440,8 @@ public sealed class GameWindow : get => _localPlayerIdentity.ServerGuid; set => _localPlayerIdentity.ServerGuid = value; } - // Retail Default_CharacterOption (acclient.h:3434). PlayerDescription - // replaces this before any in-world drag can normally occur. - private readonly PlayerCharacterOptionsState _characterOptions = new(); private readonly AcDream.App.Physics.LocalPlayerShadowState _localPlayerShadow = new(); - private readonly AcDream.App.Input.LocalPlayerSkillState _localPlayerSkills = new(); private readonly AcDream.App.Input.ViewportAspectState _viewportAspect = new(); private readonly FramebufferResizeController _framebufferResize; private AcDream.App.Input.PlayerModeController? _playerModeController; @@ -1269,7 +1265,6 @@ public sealed class GameWindow : _localPlayerIdentity, _playerControllerSlot, _localPlayerMode, - _characterOptions, viewPlane => new SelectionCameraSource( hostInputCamera.CameraController, _window!, @@ -1387,10 +1382,8 @@ public sealed class GameWindow : _liveWorldOrigin, _renderRange, _localPlayerShadow, - _localPlayerSkills, _viewportAspect, _playerApproachCompletions, - _characterOptions, _runtimeInventory, _pointerPosition, _movementInput, diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs index e68d1369..1a053e2c 100644 --- a/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs +++ b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs @@ -37,6 +37,8 @@ internal sealed class CurrentGameRuntimeAdapter LocalPlayerIdentityState playerIdentity, LiveEntityRuntime entities, RuntimeEntityObjectLifetime entityObjects, + RuntimeInventoryState inventory, + RuntimeCharacterState character, RuntimeCommunicationState communication, ILocalPlayerControllerSource playerController, WorldRevealCoordinator worldReveal, @@ -51,6 +53,8 @@ internal sealed class CurrentGameRuntimeAdapter playerIdentity, entities, entityObjects, + inventory, + character, communication, playerController, worldReveal, @@ -79,6 +83,9 @@ internal sealed class CurrentGameRuntimeAdapter public IGameRuntimeClock Clock => _view.Clock; public IRuntimeEntityView Entities => _view.Entities; public IRuntimeInventoryView Inventory => _view.Inventory; + public IRuntimeInventoryStateView InventoryState => _view.InventoryState; + public IRuntimeCharacterView Character => _view.Character; + public IRuntimeSocialView Social => _view.Social; public IRuntimeChatView Chat => _view.Chat; public IRuntimeMovementView Movement => _view.Movement; public IRuntimePortalView Portal => _view.Portal; @@ -92,6 +99,15 @@ internal sealed class CurrentGameRuntimeAdapter IRuntimeChatCommands IGameRuntimeCommands.Chat => _commands; public IRuntimePortalCommands PortalCommands => _commands; IRuntimePortalCommands IGameRuntimeCommands.Portal => _commands; + public IRuntimeInventoryStateCommands InventoryCommands => _commands; + IRuntimeInventoryStateCommands IGameRuntimeCommands.InventoryState => + _commands; + public IRuntimeSpellbookCommands SpellbookCommands => _commands; + IRuntimeSpellbookCommands IGameRuntimeCommands.Spellbook => _commands; + public IRuntimeCharacterCommands CharacterCommands => _commands; + IRuntimeCharacterCommands IGameRuntimeCommands.Character => _commands; + public IRuntimeSocialCommands SocialCommands => _commands; + IRuntimeSocialCommands IGameRuntimeCommands.Social => _commands; public RuntimeStateCheckpoint CaptureCheckpoint() => _view.CaptureCheckpoint(); diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs index 341129ae..5f5f0073 100644 --- a/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs +++ b/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs @@ -3,6 +3,7 @@ using AcDream.App.Input; using AcDream.App.Interaction; using AcDream.App.Net; using AcDream.Core.Selection; +using AcDream.Core.Items; using AcDream.Runtime; using AcDream.Runtime.Session; using AcDream.UI.Abstractions; @@ -20,7 +21,11 @@ internal sealed class CurrentGameRuntimeCommandAdapter IRuntimeCombatCommands, IRuntimeMovementCommands, IRuntimeChatCommands, - IRuntimePortalCommands + IRuntimePortalCommands, + IRuntimeInventoryStateCommands, + IRuntimeSpellbookCommands, + IRuntimeCharacterCommands, + IRuntimeSocialCommands { private readonly LiveSessionController _session; private readonly LiveSessionHost _sessionHost; @@ -276,6 +281,340 @@ internal sealed class CurrentGameRuntimeCommandAdapter return Result(RuntimeCommandStatus.Accepted); } + public RuntimeCommandResult AddShortcut( + RuntimeGenerationToken expectedGeneration, + in RuntimeShortcutCommand command) + { + RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true); + if (gate != RuntimeCommandStatus.Accepted) + return Result(gate); + if (command.Index < 0 + || (command.ObjectId == 0u && command.SpellId == 0u)) + { + return EmitResult( + RuntimeCommandDomain.InventoryState, + operation: 0, + RuntimeCommandStatus.Rejected, + command.ObjectId); + } + + _commands.Publish(new AddShortcutRuntimeCmd(new ShortcutEntry( + command.Index, + command.ObjectId, + command.SpellId))); + return EmitResult( + RuntimeCommandDomain.InventoryState, + operation: 0, + RuntimeCommandStatus.Accepted, + command.ObjectId); + } + + public RuntimeCommandResult RemoveShortcut( + RuntimeGenerationToken expectedGeneration, + int index) + { + RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true); + if (gate != RuntimeCommandStatus.Accepted) + return Result(gate); + if (index < 0) + { + return EmitResult( + RuntimeCommandDomain.InventoryState, + operation: 1, + RuntimeCommandStatus.Rejected); + } + + _commands.Publish(new RemoveShortcutRuntimeCmd((uint)index)); + return EmitResult( + RuntimeCommandDomain.InventoryState, + operation: 1, + RuntimeCommandStatus.Accepted); + } + + public RuntimeCommandResult AddFavorite( + RuntimeGenerationToken expectedGeneration, + int tabIndex, + int position, + uint spellId) + { + RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true); + if (gate != RuntimeCommandStatus.Accepted) + return Result(gate); + if ((uint)tabIndex >= 8u + || position < 0 + || spellId == 0u) + { + return EmitResult( + RuntimeCommandDomain.Spellbook, + operation: 0, + RuntimeCommandStatus.Rejected, + spellId); + } + + _commands.Publish(new AddFavoriteRuntimeCmd( + spellId, + position, + tabIndex)); + return EmitResult( + RuntimeCommandDomain.Spellbook, + operation: 0, + RuntimeCommandStatus.Accepted, + spellId); + } + + public RuntimeCommandResult RemoveFavorite( + RuntimeGenerationToken expectedGeneration, + int tabIndex, + uint spellId) + { + RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true); + if (gate != RuntimeCommandStatus.Accepted) + return Result(gate); + if ((uint)tabIndex >= 8u || spellId == 0u) + { + return EmitResult( + RuntimeCommandDomain.Spellbook, + operation: 1, + RuntimeCommandStatus.Rejected, + spellId); + } + + _commands.Publish(new RemoveFavoriteRuntimeCmd(spellId, tabIndex)); + return EmitResult( + RuntimeCommandDomain.Spellbook, + operation: 1, + RuntimeCommandStatus.Accepted, + spellId); + } + + public RuntimeCommandResult SetFilter( + RuntimeGenerationToken expectedGeneration, + uint filters) + { + RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true); + if (gate != RuntimeCommandStatus.Accepted) + return Result(gate); + _commands.Publish(new SetSpellbookFilterRuntimeCmd(filters)); + return EmitResult( + RuntimeCommandDomain.Spellbook, + operation: 2, + RuntimeCommandStatus.Accepted); + } + + public RuntimeCommandResult ForgetSpell( + RuntimeGenerationToken expectedGeneration, + uint spellId) + { + RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true); + if (gate != RuntimeCommandStatus.Accepted) + return Result(gate); + if (spellId == 0u) + { + return EmitResult( + RuntimeCommandDomain.Spellbook, + operation: 3, + RuntimeCommandStatus.Rejected, + spellId); + } + + _commands.Publish(new ForgetSpellRuntimeCmd(spellId)); + return EmitResult( + RuntimeCommandDomain.Spellbook, + operation: 3, + RuntimeCommandStatus.Accepted, + spellId); + } + + public RuntimeCommandResult SetDesiredComponent( + RuntimeGenerationToken expectedGeneration, + uint componentId, + uint amount) + { + RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true); + if (gate != RuntimeCommandStatus.Accepted) + return Result(gate); + if (componentId == 0u) + { + return EmitResult( + RuntimeCommandDomain.Spellbook, + operation: 4, + RuntimeCommandStatus.Rejected, + componentId); + } + + _commands.Publish(new SetDesiredComponentRuntimeCmd( + componentId, + amount)); + return EmitResult( + RuntimeCommandDomain.Spellbook, + operation: 4, + RuntimeCommandStatus.Accepted, + componentId); + } + + public RuntimeCommandResult ClearDesiredComponents( + RuntimeGenerationToken expectedGeneration) + { + RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true); + if (gate != RuntimeCommandStatus.Accepted) + return Result(gate); + _commands.Publish(new ClearDesiredComponentsRuntimeCmd()); + return EmitResult( + RuntimeCommandDomain.Spellbook, + operation: 5, + RuntimeCommandStatus.Accepted); + } + + public RuntimeCommandResult Advance( + RuntimeGenerationToken expectedGeneration, + in RuntimeAdvancementCommand command) + { + RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true); + if (gate != RuntimeCommandStatus.Accepted) + return Result(gate); + if (command.StatId == 0u + || command.Cost == 0u) + { + return EmitResult( + RuntimeCommandDomain.Character, + (int)command.Kind, + RuntimeCommandStatus.Rejected, + command.StatId); + } + + switch (command.Kind) + { + case RuntimeAdvancementKind.Attribute: + _commands.Publish(new RaiseAttributeRuntimeCmd( + command.StatId, + command.Cost)); + break; + case RuntimeAdvancementKind.Vital: + _commands.Publish(new RaiseVitalRuntimeCmd( + command.StatId, + command.Cost)); + break; + case RuntimeAdvancementKind.Skill: + _commands.Publish(new RaiseSkillRuntimeCmd( + command.StatId, + command.Cost)); + break; + case RuntimeAdvancementKind.TrainSkill + when command.Cost <= uint.MaxValue: + _commands.Publish(new TrainSkillRuntimeCmd( + command.StatId, + (uint)command.Cost)); + break; + default: + return EmitResult( + RuntimeCommandDomain.Character, + (int)command.Kind, + RuntimeCommandStatus.Rejected, + command.StatId); + } + + return EmitResult( + RuntimeCommandDomain.Character, + (int)command.Kind, + RuntimeCommandStatus.Accepted, + command.StatId); + } + + public RuntimeCommandResult SetOptions1( + RuntimeGenerationToken expectedGeneration, + uint options) + { + RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true); + if (gate != RuntimeCommandStatus.Accepted) + return Result(gate); + _commands.Publish(new SetCharacterOptionsRuntimeCmd(options)); + return EmitResult( + RuntimeCommandDomain.Character, + operation: 4, + RuntimeCommandStatus.Accepted); + } + + public RuntimeCommandResult Execute( + RuntimeGenerationToken expectedGeneration, + in RuntimeFriendCommand command) + { + RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true); + if (gate != RuntimeCommandStatus.Accepted) + return Result(gate); + RuntimeCommandStatus status = RuntimeCommandStatus.Accepted; + switch (command.Kind) + { + case RuntimeFriendCommandKind.Add + when !string.IsNullOrWhiteSpace(command.Name): + _commands.Publish(new AddFriendRuntimeCmd(command.Name)); + break; + case RuntimeFriendCommandKind.Remove + when command.CharacterId != 0u: + _commands.Publish(new RemoveFriendRuntimeCmd( + command.CharacterId)); + break; + case RuntimeFriendCommandKind.Clear: + _commands.Publish(new ClearFriendsRuntimeCmd()); + break; + case RuntimeFriendCommandKind.RequestLegacyList: + _commands.Publish(new RequestLegacyFriendsRuntimeCmd()); + break; + default: + status = RuntimeCommandStatus.Rejected; + break; + } + + return EmitResult( + RuntimeCommandDomain.Social, + (int)command.Kind, + status, + command.CharacterId, + command.Name); + } + + public RuntimeCommandResult Execute( + RuntimeGenerationToken expectedGeneration, + in RuntimeSquelchCommand command) + { + RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true); + if (gate != RuntimeCommandStatus.Accepted) + return Result(gate); + RuntimeCommandStatus status = RuntimeCommandStatus.Accepted; + switch (command.Scope) + { + case RuntimeSquelchScope.Character + when command.CharacterId != 0u + && !string.IsNullOrWhiteSpace(command.Name): + _commands.Publish(new ModifyCharacterSquelchRuntimeCmd( + command.Add, + command.CharacterId, + command.Name, + command.MessageType)); + break; + case RuntimeSquelchScope.Account + when !string.IsNullOrWhiteSpace(command.Name): + _commands.Publish(new ModifyAccountSquelchRuntimeCmd( + command.Add, + command.Name)); + break; + case RuntimeSquelchScope.Global: + _commands.Publish(new ModifyGlobalSquelchRuntimeCmd( + command.Add, + command.MessageType)); + break; + default: + status = RuntimeCommandStatus.Rejected; + break; + } + + return EmitResult( + RuntimeCommandDomain.Social, + operation: 4 + (int)command.Scope, + status, + command.CharacterId, + command.Name); + } + private RuntimeCommandStatus Validate( RuntimeGenerationToken expectedGeneration, bool requireWorld) @@ -294,6 +633,17 @@ internal sealed class CurrentGameRuntimeCommandAdapter uint objectId = 0u) => new(status, _view.Generation, objectId); + private RuntimeCommandResult EmitResult( + RuntimeCommandDomain domain, + int operation, + RuntimeCommandStatus status, + uint objectId = 0u, + string? text = null) + { + _events.EmitCommand(domain, operation, status, objectId, text); + return Result(status, objectId); + } + private RuntimeSessionStartResult RejectedStart(RuntimeCommandStatus status) => new( status == RuntimeCommandStatus.StaleGeneration diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeViewAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeViewAdapter.cs index 3db6aedd..ce470196 100644 --- a/src/AcDream.App/Runtime/CurrentGameRuntimeViewAdapter.cs +++ b/src/AcDream.App/Runtime/CurrentGameRuntimeViewAdapter.cs @@ -24,6 +24,9 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView private readonly IGameRuntimeClock _clock; private readonly IRuntimeEntityView _entityView; private readonly IRuntimeInventoryView _inventoryView; + private readonly IRuntimeInventoryStateView _inventoryStateView; + private readonly IRuntimeCharacterView _characterView; + private readonly IRuntimeSocialView _socialView; private readonly IRuntimeChatView _chatView; private readonly MovementView _movementView; private readonly PortalView _portalView; @@ -34,6 +37,8 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView LocalPlayerIdentityState playerIdentity, LiveEntityRuntime entities, RuntimeEntityObjectLifetime entityObjects, + RuntimeInventoryState inventory, + RuntimeCharacterState character, RuntimeCommunicationState communication, ILocalPlayerControllerSource playerController, WorldRevealCoordinator worldReveal, @@ -50,7 +55,12 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView _entityView = entityObjects.EntityView; _inventoryView = entityObjects.InventoryView; + _inventoryStateView = ( + inventory ?? throw new ArgumentNullException(nameof(inventory))).View; + _characterView = ( + character ?? throw new ArgumentNullException(nameof(character))).View; _chatView = communication.View; + _socialView = communication.SocialView; _movementView = new MovementView( playerController ?? throw new ArgumentNullException(nameof(playerController)), @@ -90,6 +100,9 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView public IGameRuntimeClock Clock => _clock; public IRuntimeEntityView Entities => _entityView; public IRuntimeInventoryView Inventory => _inventoryView; + public IRuntimeInventoryStateView InventoryState => _inventoryStateView; + public IRuntimeCharacterView Character => _characterView; + public IRuntimeSocialView Social => _socialView; public IRuntimeChatView Chat => _chatView; public IRuntimeMovementView Movement => _movementView; public IRuntimePortalView Portal => _portalView; @@ -103,6 +116,9 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView _entities.MaterializedCount, _objects.ObjectCount, _objects.ContainerCount, + _inventoryStateView.Snapshot, + _characterView.Snapshot, + _socialView.Snapshot, _chatView.Revision, _chatView.Count, _movementView.Snapshot, diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index caa0d6f3..ef3affca 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -1747,6 +1747,16 @@ public sealed class WorldSession : IDisposable SendGameAction(ClientCommandRequests.BuildEmote(seq, message)); } + /// + /// Send retail SetCharacterOptions (0x01A1) for the first character + /// option bitmap. + /// + public void SendSetCharacterOptions(uint options) + { + uint seq = NextGameActionSequence(); + SendGameAction(SocialActions.BuildSetCharacterOptions(seq, options)); + } + public void SendAddFriend(string name) { uint seq = NextGameActionSequence(); diff --git a/src/AcDream.Core/Items/ItemManaState.cs b/src/AcDream.Core/Items/ItemManaState.cs index 6d9f1c4e..13993785 100644 --- a/src/AcDream.Core/Items/ItemManaState.cs +++ b/src/AcDream.Core/Items/ItemManaState.cs @@ -12,6 +12,7 @@ namespace AcDream.Core.Items; public sealed class ItemManaState { private readonly ConcurrentDictionary _manaByGuid = new(); + private long _revision; /// Fires for every valid or invalid query response. public event Action? ItemManaChanged; @@ -20,6 +21,11 @@ public sealed class ItemManaState _manaByGuid.TryGetValue(guid, out float percent) ? percent : 0f; public bool HasMana(uint guid) => _manaByGuid.ContainsKey(guid); + public int Count => _manaByGuid.Count; + public long Revision => Interlocked.Read(ref _revision); + + public bool TryGetManaPercent(uint guid, out float fraction) => + _manaByGuid.TryGetValue(guid, out fraction); public void OnQueryItemManaResponse(uint itemGuid, float manaPercent, bool valid) { @@ -28,8 +34,13 @@ public sealed class ItemManaState else _manaByGuid.TryRemove(itemGuid, out _); + Interlocked.Increment(ref _revision); ItemManaChanged?.Invoke(itemGuid, manaPercent, valid); } - public void Clear() => _manaByGuid.Clear(); + public void Clear() + { + _manaByGuid.Clear(); + Interlocked.Increment(ref _revision); + } } diff --git a/src/AcDream.Core/Social/FriendsState.cs b/src/AcDream.Core/Social/FriendsState.cs index af079d7d..41628b2c 100644 --- a/src/AcDream.Core/Social/FriendsState.cs +++ b/src/AcDream.Core/Social/FriendsState.cs @@ -5,12 +5,29 @@ public sealed class FriendsState { private readonly object _gate = new(); private readonly List _entries = new(); + private long _revision; + + public int Count + { + get { lock (_gate) return _entries.Count; } + } + + public long Revision => Interlocked.Read(ref _revision); public IReadOnlyList Snapshot() { lock (_gate) return _entries.ToArray(); } + public bool TryGet(uint characterId, out FriendEntry? friend) + { + lock (_gate) + { + friend = _entries.Find(candidate => candidate.Id == characterId); + return friend is not null; + } + } + public void Apply(FriendsUpdate update) { ArgumentNullException.ThrowIfNull(update); @@ -43,12 +60,17 @@ public sealed class FriendsState } break; } + Interlocked.Increment(ref _revision); } } public void Clear() { - lock (_gate) _entries.Clear(); + lock (_gate) + { + _entries.Clear(); + Interlocked.Increment(ref _revision); + } } } diff --git a/src/AcDream.Core/Social/SquelchState.cs b/src/AcDream.Core/Social/SquelchState.cs index de0b8aa8..489a3112 100644 --- a/src/AcDream.Core/Social/SquelchState.cs +++ b/src/AcDream.Core/Social/SquelchState.cs @@ -5,6 +5,9 @@ public sealed class SquelchState { private readonly object _gate = new(); private SquelchDatabase _database = SquelchDatabase.Empty; + private long _revision; + + public long Revision => Interlocked.Read(ref _revision); public SquelchDatabase Snapshot() { @@ -14,7 +17,11 @@ public sealed class SquelchState public void Replace(SquelchDatabase database) { ArgumentNullException.ThrowIfNull(database); - lock (_gate) _database = database; + lock (_gate) + { + _database = database; + Interlocked.Increment(ref _revision); + } } /// @@ -25,7 +32,11 @@ public sealed class SquelchState /// public void Clear() { - lock (_gate) _database = SquelchDatabase.Empty; + lock (_gate) + { + _database = SquelchDatabase.Empty; + Interlocked.Increment(ref _revision); + } } } diff --git a/src/AcDream.Runtime/GameRuntimeCommands.cs b/src/AcDream.Runtime/GameRuntimeCommands.cs index 3ddb51b8..99b68c46 100644 --- a/src/AcDream.Runtime/GameRuntimeCommands.cs +++ b/src/AcDream.Runtime/GameRuntimeCommands.cs @@ -135,6 +135,114 @@ public interface IRuntimePortalCommands RuntimePortalCommand command); } +public readonly record struct RuntimeShortcutCommand( + int Index, + uint ObjectId, + uint SpellId); + +public interface IRuntimeInventoryStateCommands +{ + RuntimeCommandResult AddShortcut( + RuntimeGenerationToken expectedGeneration, + in RuntimeShortcutCommand command); + + RuntimeCommandResult RemoveShortcut( + RuntimeGenerationToken expectedGeneration, + int index); +} + +public interface IRuntimeSpellbookCommands +{ + RuntimeCommandResult AddFavorite( + RuntimeGenerationToken expectedGeneration, + int tabIndex, + int position, + uint spellId); + + RuntimeCommandResult RemoveFavorite( + RuntimeGenerationToken expectedGeneration, + int tabIndex, + uint spellId); + + RuntimeCommandResult SetFilter( + RuntimeGenerationToken expectedGeneration, + uint filters); + + RuntimeCommandResult ForgetSpell( + RuntimeGenerationToken expectedGeneration, + uint spellId); + + RuntimeCommandResult SetDesiredComponent( + RuntimeGenerationToken expectedGeneration, + uint componentId, + uint amount); + + RuntimeCommandResult ClearDesiredComponents( + RuntimeGenerationToken expectedGeneration); +} + +public enum RuntimeAdvancementKind +{ + Attribute, + Vital, + Skill, + TrainSkill, +} + +public readonly record struct RuntimeAdvancementCommand( + RuntimeAdvancementKind Kind, + uint StatId, + ulong Cost); + +public interface IRuntimeCharacterCommands +{ + RuntimeCommandResult Advance( + RuntimeGenerationToken expectedGeneration, + in RuntimeAdvancementCommand command); + + RuntimeCommandResult SetOptions1( + RuntimeGenerationToken expectedGeneration, + uint options); +} + +public enum RuntimeFriendCommandKind +{ + Add, + Remove, + Clear, + RequestLegacyList, +} + +public readonly record struct RuntimeFriendCommand( + RuntimeFriendCommandKind Kind, + uint CharacterId = 0u, + string? Name = null); + +public enum RuntimeSquelchScope +{ + Character, + Account, + Global, +} + +public readonly record struct RuntimeSquelchCommand( + RuntimeSquelchScope Scope, + bool Add, + uint CharacterId = 0u, + string? Name = null, + uint MessageType = 0u); + +public interface IRuntimeSocialCommands +{ + RuntimeCommandResult Execute( + RuntimeGenerationToken expectedGeneration, + in RuntimeFriendCommand command); + + RuntimeCommandResult Execute( + RuntimeGenerationToken expectedGeneration, + in RuntimeSquelchCommand command); +} + public interface IGameRuntimeCommands { IRuntimeSessionCommands Session { get; } @@ -148,4 +256,12 @@ public interface IGameRuntimeCommands IRuntimeChatCommands Chat { get; } IRuntimePortalCommands Portal { get; } + + IRuntimeInventoryStateCommands InventoryState { get; } + + IRuntimeSpellbookCommands Spellbook { get; } + + IRuntimeCharacterCommands Character { get; } + + IRuntimeSocialCommands Social { get; } } diff --git a/src/AcDream.Runtime/GameRuntimeEvents.cs b/src/AcDream.Runtime/GameRuntimeEvents.cs index 283860b9..ac02eb8f 100644 --- a/src/AcDream.Runtime/GameRuntimeEvents.cs +++ b/src/AcDream.Runtime/GameRuntimeEvents.cs @@ -13,6 +13,10 @@ public enum RuntimeCommandDomain Movement, Chat, Portal, + InventoryState, + Spellbook, + Character, + Social, } public readonly record struct RuntimeLifecycleDelta( @@ -145,6 +149,19 @@ public sealed class RuntimeTraceRecorder : IRuntimeEventObserver System.Globalization.CultureInfo.InvariantCulture, $"frame={checkpoint.FrameNumber};materialized={checkpoint.MaterializedEntityCount};" + $"containers={checkpoint.InventoryContainerCount};chat={checkpoint.ChatCount};" + + $"inventory-state={checkpoint.InventoryState.ShortcutCount}:" + + $"{checkpoint.InventoryState.ShortcutRevision}:" + + $"{checkpoint.InventoryState.ItemManaCount}:" + + $"{checkpoint.InventoryState.ItemManaRevision};" + + $"character={checkpoint.Character.CharacterRevision}:" + + $"{checkpoint.Character.SpellbookRevision}:" + + $"{checkpoint.Character.LearnedSpellCount}:" + + $"{checkpoint.Character.SkillCount}:" + + $"{checkpoint.Character.Options.Revision}:" + + $"{checkpoint.Character.MovementSkills.Revision};" + + $"social={checkpoint.Social.FriendsRevision}:" + + $"{checkpoint.Social.FriendCount}:" + + $"{checkpoint.Social.SquelchRevision};" + $"portal={checkpoint.Portal.Generation}:{(int)checkpoint.Portal.Kind}:" + $"{checkpoint.Portal.DestinationCell:X8}:{checkpoint.Portal.IsReady}"))); } diff --git a/src/AcDream.Runtime/GameRuntimeGameplayViews.cs b/src/AcDream.Runtime/GameRuntimeGameplayViews.cs new file mode 100644 index 00000000..56f2b14d --- /dev/null +++ b/src/AcDream.Runtime/GameRuntimeGameplayViews.cs @@ -0,0 +1,110 @@ +using AcDream.Runtime.Gameplay; + +namespace AcDream.Runtime; + +public readonly record struct RuntimePendingInventoryRequestSnapshot( + ulong Token, + int Kind, + uint ItemId, + bool Dispatched); + +public readonly record struct RuntimeInventoryStateSnapshot( + uint RequestedExternalContainerId, + uint CurrentExternalContainerId, + int BusyCount, + bool CanBeginRequest, + RuntimePendingInventoryRequestSnapshot? PendingRequest, + int ShortcutCount, + long ShortcutRevision, + int ItemManaCount, + long ItemManaRevision); + +public readonly record struct RuntimeShortcutSnapshot( + int Index, + uint ObjectId, + uint SpellId); + +public interface IRuntimeInventoryStateView +{ + RuntimeInventoryStateSnapshot Snapshot { get; } + + bool TryGetShortcut(int index, out RuntimeShortcutSnapshot shortcut); + + bool TryGetItemMana(uint objectId, out float fraction); +} + +public readonly record struct RuntimeCharacterSnapshot( + long CharacterRevision, + long SpellbookRevision, + RuntimeCharacterOptionsSnapshot Options, + RuntimeMovementSkillSnapshot MovementSkills, + int LearnedSpellCount, + int ActiveEnchantmentCount, + int DesiredComponentCount, + int SkillCount, + uint SpellbookFilters); + +public readonly record struct RuntimeVitalSnapshot( + int Kind, + uint Ranks, + uint Start, + uint Experience, + uint Current, + uint Maximum); + +public readonly record struct RuntimeAttributeSnapshot( + int Kind, + uint Ranks, + uint Start, + uint Experience, + uint Current); + +public readonly record struct RuntimeSkillSnapshot( + uint SkillId, + uint Ranks, + uint Status, + uint Experience, + uint Initial, + uint Resistance, + double LastUsed, + uint FormulaBonus, + uint CurrentLevel); + +public interface IRuntimeCharacterView +{ + RuntimeCharacterSnapshot Snapshot { get; } + + bool TryGetVital(int kind, out RuntimeVitalSnapshot vital); + + bool TryGetAttribute(int kind, out RuntimeAttributeSnapshot attribute); + + bool TryGetSkill(uint skillId, out RuntimeSkillSnapshot skill); + + bool KnowsSpell(uint spellId); + + bool TryGetFavorite(int tabIndex, int position, out uint spellId); + + bool TryGetDesiredComponent(uint componentId, out uint amount); +} + +public readonly record struct RuntimeSocialSnapshot( + long FriendsRevision, + int FriendCount, + long SquelchRevision, + int SquelchedAccountCount, + int SquelchedCharacterCount, + int GlobalSquelchTypeCount, + int NegotiatedChatRoomCount); + +public readonly record struct RuntimeFriendSnapshot( + uint Id, + string Name, + bool Online, + bool AppearOffline); + +public interface IRuntimeSocialView +{ + RuntimeSocialSnapshot Snapshot { get; } + + bool TryGetFriend(uint characterId, out RuntimeFriendSnapshot friend); +} diff --git a/src/AcDream.Runtime/GameRuntimeViews.cs b/src/AcDream.Runtime/GameRuntimeViews.cs index 3fb88318..df12eadf 100644 --- a/src/AcDream.Runtime/GameRuntimeViews.cs +++ b/src/AcDream.Runtime/GameRuntimeViews.cs @@ -110,6 +110,9 @@ public readonly record struct RuntimeStateCheckpoint( int MaterializedEntityCount, int InventoryObjectCount, int InventoryContainerCount, + RuntimeInventoryStateSnapshot InventoryState, + RuntimeCharacterSnapshot Character, + RuntimeSocialSnapshot Social, long ChatRevision, int ChatCount, RuntimeMovementSnapshot Movement, @@ -127,6 +130,12 @@ public interface IGameRuntimeView IRuntimeInventoryView Inventory { get; } + IRuntimeInventoryStateView InventoryState { get; } + + IRuntimeCharacterView Character { get; } + + IRuntimeSocialView Social { get; } + IRuntimeChatView Chat { get; } IRuntimeMovementView Movement { get; } diff --git a/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs b/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs index c0e54522..f5b720f3 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs @@ -1,4 +1,5 @@ using AcDream.Core.Player; +using AcDream.Core.Net.Messages; using AcDream.Core.Spells; namespace AcDream.Runtime.Gameplay; @@ -11,15 +12,27 @@ namespace AcDream.Runtime.Gameplay; public sealed class RuntimeCharacterState : IDisposable { private bool _disposed; + private long _characterRevision; + private long _spellbookRevision; public RuntimeCharacterState(SpellTable? spellTable = null) { Spellbook = new Spellbook(spellTable); LocalPlayer = new LocalPlayerState(Spellbook); + Options = new RuntimeCharacterOptionsState(); + MovementSkills = new RuntimeMovementSkillState(); + View = new CharacterView(this); + Spellbook.StateChanged += OnSpellbookChanged; + LocalPlayer.Changed += OnVitalChanged; + LocalPlayer.AttributeChanged += OnAttributeChanged; + LocalPlayer.CharacterChanged += OnCharacterChanged; } public Spellbook Spellbook { get; } public LocalPlayerState LocalPlayer { get; } + public RuntimeCharacterOptionsState Options { get; } + public RuntimeMovementSkillState MovementSkills { get; } + public IRuntimeCharacterView View { get; } public bool IsDisposed => _disposed; /// @@ -55,6 +68,8 @@ public sealed class RuntimeCharacterState : IDisposable List? failures = null; Try(Spellbook.Clear, ref failures); Try(LocalPlayer.Clear, ref failures); + Try(Options.ResetSession, ref failures); + Try(MovementSkills.ResetSession, ref failures); if (failures is not null) { throw new AggregateException( @@ -68,9 +83,25 @@ public sealed class RuntimeCharacterState : IDisposable if (_disposed) return; ResetSession(); + Spellbook.StateChanged -= OnSpellbookChanged; + LocalPlayer.Changed -= OnVitalChanged; + LocalPlayer.AttributeChanged -= OnAttributeChanged; + LocalPlayer.CharacterChanged -= OnCharacterChanged; _disposed = true; } + private void OnSpellbookChanged() => + Interlocked.Increment(ref _spellbookRevision); + + private void OnVitalChanged(LocalPlayerState.VitalKind _) => + Interlocked.Increment(ref _characterRevision); + + private void OnAttributeChanged(LocalPlayerState.AttributeKind _) => + Interlocked.Increment(ref _characterRevision); + + private void OnCharacterChanged() => + Interlocked.Increment(ref _characterRevision); + private static void Try(Action action, ref List? failures) { try @@ -82,4 +113,212 @@ public sealed class RuntimeCharacterState : IDisposable (failures ??= []).Add(error); } } + + private sealed class CharacterView(RuntimeCharacterState owner) + : IRuntimeCharacterView + { + public RuntimeCharacterSnapshot Snapshot => new( + Interlocked.Read(ref owner._characterRevision), + Interlocked.Read(ref owner._spellbookRevision), + owner.Options.Snapshot, + owner.MovementSkills.Snapshot, + owner.Spellbook.LearnedSpells.Count, + owner.Spellbook.ActiveEnchantments.Count(), + owner.Spellbook.DesiredComponents.Count, + owner.LocalPlayer.Skills.Count, + owner.Spellbook.SpellbookFilters); + + public bool TryGetVital(int kind, out RuntimeVitalSnapshot vital) + { + if (!Enum.IsDefined((LocalPlayerState.VitalKind)kind) + || owner.LocalPlayer.Get((LocalPlayerState.VitalKind)kind) + is not LocalPlayerState.VitalSnapshot current) + { + vital = default; + return false; + } + + vital = new RuntimeVitalSnapshot( + kind, + current.Ranks, + current.Start, + current.Xp, + current.Current, + owner.LocalPlayer.GetMaxApprox( + (LocalPlayerState.VitalKind)kind) ?? 0u); + return true; + } + + public bool TryGetAttribute( + int kind, + out RuntimeAttributeSnapshot attribute) + { + if (!Enum.IsDefined((LocalPlayerState.AttributeKind)kind) + || owner.LocalPlayer.GetAttribute( + (LocalPlayerState.AttributeKind)kind) + is not LocalPlayerState.AttributeSnapshot current) + { + attribute = default; + return false; + } + + attribute = new RuntimeAttributeSnapshot( + kind, + current.Ranks, + current.Start, + current.Xp, + current.Current); + return true; + } + + public bool TryGetSkill(uint skillId, out RuntimeSkillSnapshot skill) + { + if (owner.LocalPlayer.GetSkill(skillId) + is not LocalPlayerState.SkillSnapshot current) + { + skill = default; + return false; + } + + skill = new RuntimeSkillSnapshot( + current.SkillId, + current.Ranks, + current.Status, + current.Xp, + current.Init, + current.Resistance, + current.LastUsed, + current.FormulaBonus, + current.CurrentLevel); + return true; + } + + public bool KnowsSpell(uint spellId) => + owner.Spellbook.Knows(spellId); + + public bool TryGetFavorite( + int tabIndex, + int position, + out uint spellId) + { + IReadOnlyList favorites = + owner.Spellbook.GetFavorites(tabIndex); + if ((uint)position >= (uint)favorites.Count) + { + spellId = 0u; + return false; + } + spellId = favorites[position]; + return true; + } + + public bool TryGetDesiredComponent( + uint componentId, + out uint amount) => + owner.Spellbook.DesiredComponents.TryGetValue( + componentId, + out amount); + } +} + +public readonly record struct RuntimeCharacterOptionsSnapshot( + uint Options1, + uint Options2, + long Revision) +{ + public bool DragItemOnPlayerOpensSecureTrade => + (Options1 + & (uint)PlayerDescriptionParser.CharacterOptions1 + .DragItemOnPlayerOpensSecureTrade) != 0u; +} + +/// +/// Canonical session-owned copy of retail's two character-option bitfields. +/// PlayerModule::PlayerModule @ 0x005D51F0 installs the defaults. +/// Runtime reset restores the equivalent fresh-player-module state because +/// one Runtime owner survives across graphical and no-window sessions. +/// +public sealed class RuntimeCharacterOptionsState +{ + public const uint DefaultOptions1 = + (uint)PlayerDescriptionParser.CharacterOptions1.Default; + public const uint DefaultOptions2 = 0x00948700u; + + private uint _options1 = DefaultOptions1; + private uint _options2 = DefaultOptions2; + private long _revision; + + public uint Options1 => Volatile.Read(ref _options1); + public uint Options2 => Volatile.Read(ref _options2); + public long Revision => Interlocked.Read(ref _revision); + public RuntimeCharacterOptionsSnapshot Snapshot => + new(_options1, _options2, Revision); + + public bool DragItemOnPlayerOpensSecureTrade => + Snapshot.DragItemOnPlayerOpensSecureTrade; + + public void Replace(uint options1, uint options2) + { + Volatile.Write(ref _options1, options1); + Volatile.Write(ref _options2, options2); + Interlocked.Increment(ref _revision); + } + + public void ResetSession() + { + Volatile.Write(ref _options1, DefaultOptions1); + Volatile.Write(ref _options2, DefaultOptions2); + Interlocked.Increment(ref _revision); + } +} + +public readonly record struct RuntimeMovementSkillSnapshot( + int RunSkill, + int JumpSkill, + long Revision) +{ + public bool IsComplete => RunSkill >= 0 && JumpSkill >= 0; +} + +/// +/// Server-authoritative run/jump values retained independently of any +/// graphical movement controller. App applies this borrowed state whenever +/// its presentation/physics controller exists or is rebuilt. +/// +public sealed class RuntimeMovementSkillState +{ + private int _runSkill = -1; + private int _jumpSkill = -1; + private long _revision; + + public int RunSkill => Volatile.Read(ref _runSkill); + public int JumpSkill => Volatile.Read(ref _jumpSkill); + public bool IsComplete => _runSkill >= 0 && _jumpSkill >= 0; + public long Revision => Interlocked.Read(ref _revision); + public RuntimeMovementSkillSnapshot Snapshot => + new(_runSkill, _jumpSkill, Revision); + + public void Update(int runSkill, int jumpSkill) + { + bool changed = false; + if (runSkill >= 0 && RunSkill != runSkill) + { + Volatile.Write(ref _runSkill, runSkill); + changed = true; + } + if (jumpSkill >= 0 && JumpSkill != jumpSkill) + { + Volatile.Write(ref _jumpSkill, jumpSkill); + changed = true; + } + if (changed) + Interlocked.Increment(ref _revision); + } + + public void ResetSession() + { + Volatile.Write(ref _runSkill, -1); + Volatile.Write(ref _jumpSkill, -1); + Interlocked.Increment(ref _revision); + } } diff --git a/src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs b/src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs index 025a0c88..46965a31 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs @@ -36,6 +36,10 @@ public sealed class RuntimeCommunicationState : IDisposable Friends = new FriendsState(); Squelch = new SquelchState(); View = new CommunicationView(Chat); + SocialView = new CommunicationSocialView( + TurbineChat, + Friends, + Squelch); } public ChatLog Chat { get; } @@ -44,6 +48,7 @@ public sealed class RuntimeCommunicationState : IDisposable public FriendsState Friends { get; } public SquelchState Squelch { get; } public IRuntimeChatView View { get; } + public IRuntimeSocialView SocialView { get; } public IRuntimeCommunicationEventSource Events => _events; public bool IsDisposed => _disposed; @@ -79,6 +84,61 @@ public sealed class RuntimeCommunicationState : IDisposable public long Revision => chat.Revision; public int Count => chat.Count; } + + private sealed class CommunicationSocialView( + TurbineChatState turbineChat, + FriendsState friends, + SquelchState squelch) + : IRuntimeSocialView + { + public RuntimeSocialSnapshot Snapshot + { + get + { + SquelchDatabase database = squelch.Snapshot(); + return new RuntimeSocialSnapshot( + friends.Revision, + friends.Count, + squelch.Revision, + database.Accounts.Count, + database.Characters.Count, + database.Global.MessageTypes.Count, + CountRooms(turbineChat)); + } + } + + public bool TryGetFriend( + uint characterId, + out RuntimeFriendSnapshot friend) + { + if (!friends.TryGet(characterId, out FriendEntry? current) + || current is null) + { + friend = default; + return false; + } + + friend = new RuntimeFriendSnapshot( + current.Id, + current.Name, + current.Online, + current.AppearOffline); + return true; + } + + private static int CountRooms(TurbineChatState state) + { + int count = 0; + if (state.AllegianceRoom != 0u) count++; + if (state.GeneralRoom != 0u) count++; + if (state.TradeRoom != 0u) count++; + if (state.LfgRoom != 0u) count++; + if (state.RoleplayRoom != 0u) count++; + if (state.SocietyRoom != 0u) count++; + if (state.OlthoiRoom != 0u) count++; + return count; + } + } } internal sealed class RuntimeCommunicationEventStream diff --git a/src/AcDream.Runtime/Gameplay/RuntimeInventoryState.cs b/src/AcDream.Runtime/Gameplay/RuntimeInventoryState.cs index fe4eeb97..525dbcc9 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeInventoryState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeInventoryState.cs @@ -21,6 +21,7 @@ public sealed class RuntimeInventoryState : IDisposable ItemMana = new ItemManaState(); Shortcuts = new ShortcutState(); Transactions = new InventoryTransactionState(_entityObjects.Objects); + View = new InventoryStateView(this); } public ClientObjectTable Objects => _entityObjects.Objects; @@ -28,6 +29,7 @@ public sealed class RuntimeInventoryState : IDisposable public ItemManaState ItemMana { get; } public ShortcutState Shortcuts { get; } public InventoryTransactionState Transactions { get; } + public IRuntimeInventoryStateView View { get; } public bool IsDisposed => _disposed; public void ResetExternalContainer() => ExternalContainers.Reset(); @@ -66,6 +68,61 @@ public sealed class RuntimeInventoryState : IDisposable (failures ??= []).Add(error); } } + + private sealed class InventoryStateView(RuntimeInventoryState owner) + : IRuntimeInventoryStateView + { + public RuntimeInventoryStateSnapshot Snapshot + { + get + { + RuntimePendingInventoryRequestSnapshot? pending = null; + if (owner.Transactions.TryGetPending( + out PendingInventoryRequest request)) + { + pending = new RuntimePendingInventoryRequestSnapshot( + request.Token, + (int)request.Kind, + request.ItemId, + request.Dispatched); + } + + return new RuntimeInventoryStateSnapshot( + owner.ExternalContainers.RequestedContainerId, + owner.ExternalContainers.CurrentContainerId, + owner.Transactions.BusyCount, + owner.Transactions.CanBeginRequest, + pending, + owner.Shortcuts.Items.Count, + owner.Shortcuts.Revision, + owner.ItemMana.Count, + owner.ItemMana.Revision); + } + } + + public bool TryGetShortcut( + int index, + out RuntimeShortcutSnapshot shortcut) + { + IReadOnlyList shortcuts = owner.Shortcuts.Items; + for (int i = 0; i < shortcuts.Count; i++) + { + if (shortcuts[i].Index != index) + continue; + ShortcutEntry current = shortcuts[i]; + shortcut = new RuntimeShortcutSnapshot( + current.Index, + current.ObjectId, + current.SpellId); + return true; + } + shortcut = default; + return false; + } + + public bool TryGetItemMana(uint objectId, out float fraction) => + owner.ItemMana.TryGetManaPercent(objectId, out fraction); + } } public sealed class ShortcutState diff --git a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs index 2259f51c..90ea6cd4 100644 --- a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs +++ b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs @@ -44,7 +44,6 @@ public sealed record LiveCharacterSessionBindings( Action? OnSkillsUpdated, Action? OnConfirmationRequest, Action? OnConfirmationDone, - Action? OnCharacterOptions, Func? ClientTime); public sealed record LiveSocialSessionBindings( @@ -152,7 +151,13 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting social.Chat, character.Character.LocalPlayer, social.TurbineChat, - onSkillsUpdated: character.OnSkillsUpdated, + onSkillsUpdated: (runSkill, jumpSkill) => + { + character.Character.MovementSkills.Update( + runSkill, + jumpSkill); + character.OnSkillsUpdated?.Invoke(runSkill, jumpSkill); + }, resolveSkillFormulaBonus: character.ResolveSkillFormulaBonus, onShortcuts: inventory.OnShortcuts, playerGuid: inventory.PlayerGuid, @@ -164,7 +169,7 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting friends: social.Friends, squelch: social.Squelch, onDesiredComponents: null, - onCharacterOptions: character.OnCharacterOptions, + onCharacterOptions: character.Character.Options.Replace, clientTime: character.ClientTime, externalContainers: inventory.ExternalContainers, accepting: IsAccepting)); diff --git a/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs b/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs index 2651b51f..2a257f37 100644 --- a/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs +++ b/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs @@ -199,7 +199,6 @@ public sealed class InteractionRetainedUiCompositionTests PlayerIdentity: null!, PlayerController: null!, PlayerMode: null!, - CharacterOptions: null!, SelectionCameraFactory: static _ => Stub(), FrameDiagnostics: new DeferredRenderFrameDiagnosticsSource(), ExistingVitals: null, diff --git a/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs b/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs index 6284b6b7..4aacf352 100644 --- a/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs +++ b/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs @@ -10,6 +10,7 @@ using AcDream.App.UI.Layout; using AcDream.App.World; using AcDream.Core.Net; using AcDream.Core.World; +using AcDream.Runtime; using AcDream.UI.Abstractions; namespace AcDream.App.Tests.Composition; @@ -67,6 +68,44 @@ public sealed class InteractionUiRuntimeSourcesTests Assert.Throws(() => source.Bind(query, interactions)); } + [Fact] + public void GameRuntimeCommandsCaptureTheExactCurrentGeneration() + { + var source = new DeferredGameRuntimeStateCommands(); + var first = new RuntimeTarget(new RuntimeGenerationToken(7)); + var second = new RuntimeTarget(new RuntimeGenerationToken(9)); + + Assert.False(source.IsInWorld); + Assert.Equal( + RuntimeCommandStatus.Inactive, + source.Advance(RuntimeAdvancementKind.Skill, 6u, 100u).Status); + + IDisposable stale = source.Bind(first, first); + Assert.True(source.IsInWorld); + Assert.True(source.Advance( + RuntimeAdvancementKind.Skill, + 6u, + 100u).Accepted); + Assert.Equal(new RuntimeGenerationToken(7), first.LastGeneration); + Assert.Throws(() => + source.Bind(second, second)); + + stale.Dispose(); + using IDisposable current = source.Bind(second, second); + stale.Dispose(); + Assert.True(source.AddFavorite(0, 2, 42u).Accepted); + Assert.Equal(new RuntimeGenerationToken(9), second.LastGeneration); + Assert.Equal( + RuntimeCommandStatus.Rejected, + source.RemoveShortcut(uint.MaxValue).Status); + + source.Deactivate(); + Assert.Equal( + RuntimeCommandStatus.Inactive, + source.RemoveShortcut(3u).Status); + Assert.Throws(() => source.Bind(first, first)); + } + [Fact] public void RadarNeverCachesAnUnboundBootstrapSnapshot() { @@ -172,6 +211,109 @@ public sealed class InteractionUiRuntimeSourcesTests public ICommandBus Commands { get; } = new LiveCommandBus(); } + private sealed class RuntimeTarget + : IGameRuntimeView, + IGameRuntimeCommands, + IRuntimeInventoryStateCommands, + IRuntimeSpellbookCommands, + IRuntimeCharacterCommands + { + public RuntimeTarget(RuntimeGenerationToken generation) + { + Generation = generation; + } + + public RuntimeGenerationToken LastGeneration { get; private set; } + public RuntimeGenerationToken Generation { get; } + public RuntimeLifecycleSnapshot Lifecycle => + new(Generation, RuntimeLifecycleState.InWorld, 1u, true); + public IGameRuntimeClock Clock => null!; + public IRuntimeEntityView Entities => null!; + public IRuntimeInventoryView Inventory => null!; + public IRuntimeInventoryStateView InventoryState => null!; + public IRuntimeCharacterView Character => null!; + public IRuntimeSocialView Social => null!; + public IRuntimeChatView Chat => null!; + public IRuntimeMovementView Movement => null!; + public IRuntimePortalView Portal => null!; + public IRuntimeSessionCommands Session => null!; + public IRuntimeSelectionCommands Selection => null!; + public IRuntimeCombatCommands Combat => null!; + IRuntimeMovementCommands IGameRuntimeCommands.Movement => null!; + IRuntimeChatCommands IGameRuntimeCommands.Chat => null!; + IRuntimePortalCommands IGameRuntimeCommands.Portal => null!; + IRuntimeInventoryStateCommands IGameRuntimeCommands.InventoryState => this; + IRuntimeSpellbookCommands IGameRuntimeCommands.Spellbook => this; + IRuntimeCharacterCommands IGameRuntimeCommands.Character => this; + IRuntimeSocialCommands IGameRuntimeCommands.Social => null!; + + public RuntimeStateCheckpoint CaptureCheckpoint() => default; + + public RuntimeCommandResult AddShortcut( + RuntimeGenerationToken expectedGeneration, + in RuntimeShortcutCommand command) => + Accepted(expectedGeneration, command.ObjectId); + + public RuntimeCommandResult RemoveShortcut( + RuntimeGenerationToken expectedGeneration, + int index) => + Accepted(expectedGeneration); + + public RuntimeCommandResult AddFavorite( + RuntimeGenerationToken expectedGeneration, + int tabIndex, + int position, + uint spellId) => + Accepted(expectedGeneration, spellId); + + public RuntimeCommandResult RemoveFavorite( + RuntimeGenerationToken expectedGeneration, + int tabIndex, + uint spellId) => + Accepted(expectedGeneration, spellId); + + public RuntimeCommandResult SetFilter( + RuntimeGenerationToken expectedGeneration, + uint filters) => + Accepted(expectedGeneration); + + public RuntimeCommandResult ForgetSpell( + RuntimeGenerationToken expectedGeneration, + uint spellId) => + Accepted(expectedGeneration, spellId); + + public RuntimeCommandResult SetDesiredComponent( + RuntimeGenerationToken expectedGeneration, + uint componentId, + uint amount) => + Accepted(expectedGeneration, componentId); + + public RuntimeCommandResult ClearDesiredComponents( + RuntimeGenerationToken expectedGeneration) => + Accepted(expectedGeneration); + + public RuntimeCommandResult Advance( + RuntimeGenerationToken expectedGeneration, + in RuntimeAdvancementCommand command) => + Accepted(expectedGeneration, command.StatId); + + public RuntimeCommandResult SetOptions1( + RuntimeGenerationToken expectedGeneration, + uint options) => + Accepted(expectedGeneration); + + private RuntimeCommandResult Accepted( + RuntimeGenerationToken generation, + uint objectId = 0u) + { + LastGeneration = generation; + return new RuntimeCommandResult( + RuntimeCommandStatus.Accepted, + generation, + objectId); + } + } + private sealed class SelectionQuery : IRetainedUiSelectionQuery { public bool ShouldShowHealth(uint serverGuid) => true; diff --git a/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs b/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs index 6af9813b..04af1c64 100644 --- a/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs +++ b/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs @@ -193,6 +193,11 @@ public sealed class GameplayInputCommandControllerTests public IGameRuntimeClock Clock => throw new NotSupportedException(); public IRuntimeEntityView Entities => throw new NotSupportedException(); public IRuntimeInventoryView Inventory => throw new NotSupportedException(); + public IRuntimeInventoryStateView InventoryState => + throw new NotSupportedException(); + public IRuntimeCharacterView Character => + throw new NotSupportedException(); + public IRuntimeSocialView Social => throw new NotSupportedException(); public IRuntimeChatView Chat => throw new NotSupportedException(); public IRuntimeMovementView Movement => throw new NotSupportedException(); public IRuntimePortalView Portal => throw new NotSupportedException(); diff --git a/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs index be2d729d..e59daf1c 100644 --- a/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs +++ b/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs @@ -2,6 +2,7 @@ using System.Runtime.CompilerServices; using AcDream.App.Net; using AcDream.App.UI; using AcDream.Core.Chat; +using AcDream.Core.Items; using AcDream.Core.Social; using AcDream.UI.Abstractions; @@ -220,6 +221,35 @@ public sealed class LiveSessionCommandRouterTests GC.KeepAlive(router); } + [Fact] + public void RuntimeStateCommandsUseActiveGenerationRouteAndBecomeInert() + { + var shortcuts = new List(); + var options = new List(); + LiveSessionCommandRouter router = NewRouter( + addShortcut: shortcuts.Add, + setCharacterOptions: options.Add); + + router.Publish(new AddShortcutRuntimeCmd( + new ShortcutEntry(1, 0x80000001u, 0u))); + router.Activate(); + router.Publish(new AddShortcutRuntimeCmd( + new ShortcutEntry(2, 0x80000002u, 0u))); + router.Publish(new SetCharacterOptionsRuntimeCmd(0x50C4A54Au)); + router.Dispose(); + router.Publish(new AddShortcutRuntimeCmd( + new ShortcutEntry(3, 0x80000003u, 0u))); + + Assert.Collection( + shortcuts, + entry => + { + Assert.Equal(2, entry.Index); + Assert.Equal(0x80000002u, entry.ObjectId); + }); + Assert.Equal([0x50C4A54Au], options); + } + private static LiveSessionCommandRouter NewRouter( ChatLog? chat = null, TurbineChatState? turbine = null, @@ -228,6 +258,8 @@ public sealed class LiveSessionCommandRouterTests Action? sendTell = null, Action? sendChannel = null, Action? sendTurbine = null, + Action? addShortcut = null, + Action? setCharacterOptions = null, Action? log = null, ClientCommandController.Bindings? clientBindings = null) => new( new LiveSessionCommandBindings( @@ -239,7 +271,27 @@ public sealed class LiveSessionCommandRouterTests sendTell ?? ((_, _) => { }), sendChannel ?? ((_, _) => { }), sendTurbine ?? ((_, _, _, _, _, _) => { }), - log)); + AddShortcut: addShortcut ?? (_ => { }), + RemoveShortcut: _ => { }, + AddFavorite: (_, _, _) => { }, + RemoveFavorite: (_, _) => { }, + SetSpellbookFilter: _ => { }, + ForgetSpell: _ => { }, + SetDesiredComponent: (_, _) => { }, + ClearDesiredComponents: () => { }, + RaiseAttribute: (_, _) => { }, + RaiseVital: (_, _) => { }, + RaiseSkill: (_, _) => { }, + TrainSkill: (_, _) => { }, + SetCharacterOptions: setCharacterOptions ?? (_ => { }), + AddFriend: _ => { }, + RemoveFriend: _ => { }, + ClearFriends: () => { }, + RequestLegacyFriends: () => { }, + ModifyCharacterSquelch: (_, _, _, _) => { }, + ModifyAccountSquelch: (_, _) => { }, + ModifyGlobalSquelch: (_, _) => { }, + Log: log)); [MethodImpl(MethodImplOptions.NoInlining)] private static (LiveSessionCommandRouter, WeakReference) diff --git a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs index 5d6f6dfe..f333fb29 100644 --- a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs +++ b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs @@ -15,6 +15,7 @@ using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; using AcDream.Core.Selection; +using AcDream.Core.Social; using AcDream.Core.World; using AcDream.Runtime; using AcDream.Runtime.Entities; @@ -363,6 +364,157 @@ public sealed class CurrentGameRuntimeAdapterTests Assert.Equal(0, harness.EntityObjects.Events.SubscriberCount); } + [Fact] + public void AdapterBorrowsExactJ4ViewsAndCheckpointState() + { + using var harness = new Harness(); + harness.Character.Options.Replace(0x04000000u, 0x00948700u); + harness.Character.MovementSkills.Update(200, 175); + harness.Character.Spellbook.OnSpellLearned(42u); + harness.InventoryState.Shortcuts.Replace( + [ + new ShortcutEntry(1, 0x80000001u, 0u), + ]); + harness.Communication.Friends.Apply(new FriendsUpdate( + FriendsUpdateType.Full, + [ + new FriendEntry( + 0x50000003u, + "Borrowed", + true, + false, + [], + []), + ])); + + Assert.Same(harness.Character.View, harness.Runtime.Character); + Assert.Same( + harness.InventoryState.View, + harness.Runtime.InventoryState); + Assert.Same( + harness.Communication.SocialView, + harness.Runtime.Social); + + RuntimeStateCheckpoint checkpoint = + harness.Runtime.CaptureCheckpoint(); + Assert.Equal(1, checkpoint.Character.LearnedSpellCount); + Assert.True(checkpoint.Character.MovementSkills.IsComplete); + Assert.Equal(1, checkpoint.InventoryState.ShortcutCount); + Assert.Equal(1, checkpoint.Social.FriendCount); + } + + [Fact] + public void J4StateCommandsAreGenerationGatedAndReachCurrentTransport() + { + using var harness = new Harness(); + var trace = new RuntimeTraceRecorder(); + using IDisposable subscription = harness.Runtime.Subscribe(trace); + _ = harness.Runtime.Session.Start(harness.Runtime.Generation); + RuntimeGenerationToken generation = harness.Runtime.Generation; + IGameRuntimeCommands commands = harness.Runtime; + + RuntimeCommandResult shortcut = commands.InventoryState.AddShortcut( + generation, + new RuntimeShortcutCommand(2, 0x80000001u, 0u)); + RuntimeCommandResult favorite = commands.Spellbook.AddFavorite( + generation, + tabIndex: 0, + position: 0, + spellId: 42u); + RuntimeCommandResult filter = commands.Spellbook.SetFilter( + generation, + 0x3FFEu); + RuntimeCommandResult desired = commands.Spellbook.SetDesiredComponent( + generation, + 0x68000001u, + 10u); + RuntimeCommandResult advancement = commands.Character.Advance( + generation, + new RuntimeAdvancementCommand( + RuntimeAdvancementKind.Skill, + StatId: 6u, + Cost: 500u)); + RuntimeCommandResult options = commands.Character.SetOptions1( + generation, + 0x50C4A54Au); + RuntimeCommandResult friend = commands.Social.Execute( + generation, + new RuntimeFriendCommand( + RuntimeFriendCommandKind.Add, + Name: "Runtime Friend")); + RuntimeCommandResult squelch = commands.Social.Execute( + generation, + new RuntimeSquelchCommand( + RuntimeSquelchScope.Global, + Add: true, + MessageType: 3u)); + + Assert.All( + new[] + { + shortcut, + favorite, + filter, + desired, + advancement, + options, + friend, + squelch, + }, + static result => Assert.True(result.Accepted)); + Assert.Contains( + harness.Commands.Published, + static command => command is AddShortcutRuntimeCmd); + Assert.Contains( + harness.Commands.Published, + static command => command is AddFavoriteRuntimeCmd); + Assert.Contains( + harness.Commands.Published, + static command => command is SetSpellbookFilterRuntimeCmd); + Assert.Contains( + harness.Commands.Published, + static command => command is SetDesiredComponentRuntimeCmd); + Assert.Contains( + harness.Commands.Published, + static command => command is RaiseSkillRuntimeCmd); + Assert.Contains( + harness.Commands.Published, + static command => command is SetCharacterOptionsRuntimeCmd); + Assert.Contains( + harness.Commands.Published, + static command => command is AddFriendRuntimeCmd); + Assert.Contains( + harness.Commands.Published, + static command => command is ModifyGlobalSquelchRuntimeCmd); + Assert.Contains( + trace.Entries, + static entry => entry.Kind == RuntimeTraceKind.Command + && (entry.Code >> 16) + == (int)RuntimeCommandDomain.InventoryState); + Assert.Contains( + trace.Entries, + static entry => entry.Kind == RuntimeTraceKind.Command + && (entry.Code >> 16) + == (int)RuntimeCommandDomain.Spellbook); + Assert.Contains( + trace.Entries, + static entry => entry.Kind == RuntimeTraceKind.Command + && (entry.Code >> 16) + == (int)RuntimeCommandDomain.Character); + Assert.Contains( + trace.Entries, + static entry => entry.Kind == RuntimeTraceKind.Command + && (entry.Code >> 16) + == (int)RuntimeCommandDomain.Social); + + int published = harness.Commands.Published.Count; + RuntimeCommandResult stale = commands.Character.SetOptions1( + new RuntimeGenerationToken(generation.Value - 1), + 0u); + Assert.Equal(RuntimeCommandStatus.StaleGeneration, stale.Status); + Assert.Equal(published, harness.Commands.Published.Count); + } + private static ClientObject Object(WorldSession.EntitySpawn spawn) => new() { ObjectId = spawn.Guid, @@ -407,6 +559,8 @@ public sealed class CurrentGameRuntimeAdapterTests { Identity = new LocalPlayerIdentityState(); EntityObjects = new RuntimeEntityObjectLifetime(); + InventoryState = new RuntimeInventoryState(EntityObjects); + Character = new RuntimeCharacterState(); Entities = new LiveEntityRuntime( new GpuWorldState(), new NoopEntityResources(), @@ -448,7 +602,9 @@ public sealed class CurrentGameRuntimeAdapterTests Options = LiveOptions(); Commands = new RecordingCommandRouting(); - _session = new LiveSessionController(new SessionOperations()); + Transport = new TestTransport(); + _session = new LiveSessionController( + new SessionOperations(Transport)); Host = CreateHost(_session, Commands, Identity); Runtime = new CurrentGameRuntimeAdapter( _session, @@ -457,6 +613,8 @@ public sealed class CurrentGameRuntimeAdapterTests Identity, Entities, EntityObjects, + InventoryState, + Character, Communication, new LocalPlayerControllerSlot(), WorldReveal, @@ -470,6 +628,8 @@ public sealed class CurrentGameRuntimeAdapterTests public RuntimeOptions Options { get; } public LocalPlayerIdentityState Identity { get; } public RuntimeEntityObjectLifetime EntityObjects { get; } + public RuntimeInventoryState InventoryState { get; } + public RuntimeCharacterState Character { get; } public LiveEntityRuntime Entities { get; } public ClientObjectTable Objects { get; } public RuntimeCommunicationState Communication { get; } @@ -481,12 +641,15 @@ public sealed class CurrentGameRuntimeAdapterTests public UpdateFrameClock Clock { get; } public WorldRevealCoordinator WorldReveal { get; } public RecordingCommandRouting Commands { get; } + public TestTransport Transport { get; } public LiveSessionHost Host { get; } public CurrentGameRuntimeAdapter Runtime { get; } public void Dispose() { Runtime.Dispose(); + Character.Dispose(); + InventoryState.Dispose(); Communication.Dispose(); _session.Dispose(); _items.Dispose(); @@ -644,13 +807,14 @@ public sealed class CurrentGameRuntimeAdapterTests Physics: physics); } - private sealed class SessionOperations : ILiveSessionOperations + private sealed class SessionOperations(TestTransport transport) + : ILiveSessionOperations { public IPEndPoint ResolveEndpoint(string host, int port) => new(IPAddress.Loopback, port); public WorldSession CreateSession(IPEndPoint endpoint) => - new(endpoint, new TestTransport()); + new(endpoint, transport); public void Connect(WorldSession session, string user, string password) { @@ -686,14 +850,18 @@ public sealed class CurrentGameRuntimeAdapterTests public void DisposeSession(WorldSession session) => session.Dispose(); } - private sealed class TestTransport : IWorldSessionTransport + internal sealed class TestTransport : IWorldSessionTransport { + public List Sent { get; } = []; + public void Send(ReadOnlySpan datagram) { + Sent.Add(datagram.ToArray()); } public void Send(IPEndPoint remote, ReadOnlySpan datagram) { + Sent.Add(datagram.ToArray()); } public int Receive( diff --git a/tests/AcDream.App.Tests/Runtime/RuntimeCharacterOwnershipTests.cs b/tests/AcDream.App.Tests/Runtime/RuntimeCharacterOwnershipTests.cs index 0833982f..a8c299c8 100644 --- a/tests/AcDream.App.Tests/Runtime/RuntimeCharacterOwnershipTests.cs +++ b/tests/AcDream.App.Tests/Runtime/RuntimeCharacterOwnershipTests.cs @@ -81,6 +81,37 @@ public sealed class RuntimeCharacterOwnershipTests StringComparison.Ordinal); } + [Fact] + public void AppCharacterOptionAndMovementSkillOwnersWereDeleted() + { + string gameWindow = ReadSource("Rendering", "GameWindow.cs"); + string sources = ReadSource( + "Composition", + "InteractionUiRuntimeSources.cs"); + string playerState = ReadSource("Input", "LocalPlayerRuntimeState.cs"); + string session = ReadSource("Net", "LiveSessionRuntimeFactory.cs"); + string router = ReadRuntimeSource( + "Session", + "LiveSessionEventRouter.cs"); + + Assert.DoesNotContain( + "PlayerCharacterOptionsState", + gameWindow + sources + session, + StringComparison.Ordinal); + Assert.DoesNotContain( + "LocalPlayerSkillState", + gameWindow + playerState + session, + StringComparison.Ordinal); + Assert.Contains( + "character.Character.Options.Replace", + router, + StringComparison.Ordinal); + Assert.Contains( + "character.Character.MovementSkills.Update", + router, + StringComparison.Ordinal); + } + private static string ReadSource(params string[] relative) => File.ReadAllText(Path.Combine( [FindRepositoryRoot(), "src", "AcDream.App", .. relative])); diff --git a/tests/AcDream.Runtime.Tests/GameRuntimeContractTests.cs b/tests/AcDream.Runtime.Tests/GameRuntimeContractTests.cs index 373e71c6..c762055c 100644 --- a/tests/AcDream.Runtime.Tests/GameRuntimeContractTests.cs +++ b/tests/AcDream.Runtime.Tests/GameRuntimeContractTests.cs @@ -1,3 +1,6 @@ +using System.Reflection; +using AcDream.Runtime.Gameplay; + namespace AcDream.Runtime.Tests; public sealed class GameRuntimeContractTests @@ -137,4 +140,68 @@ public sealed class GameRuntimeContractTests Assert.Equal("General|Grey|hello", entry.Text); }); } + + [Fact] + public void TraceRecorderIncludesJ4GameplayStateInCheckpoint() + { + var recorder = new RuntimeTraceRecorder(); + var stamp = new RuntimeEventStamp(new(3), 1, 8); + var checkpoint = new RuntimeStateCheckpoint( + new RuntimeGenerationToken(3), + RuntimeLifecycleState.InWorld, + FrameNumber: 8, + EntityCount: 2, + MaterializedEntityCount: 2, + InventoryObjectCount: 1, + InventoryContainerCount: 1, + new RuntimeInventoryStateSnapshot( + 0u, 0u, 0, true, null, 2, 4, 1, 3), + new RuntimeCharacterSnapshot( + CharacterRevision: 5, + SpellbookRevision: 6, + default, + default, + LearnedSpellCount: 7, + ActiveEnchantmentCount: 0, + DesiredComponentCount: 0, + SkillCount: 8, + SpellbookFilters: 0x3FFFu), + new RuntimeSocialSnapshot(9, 10, 11, 0, 0, 0, 0), + ChatRevision: 12, + ChatCount: 13, + default, + default); + + recorder.AddCheckpoint(stamp, checkpoint); + + RuntimeTraceEntry entry = Assert.Single(recorder.Entries); + Assert.Contains("inventory-state=2:4:1:3", entry.Text); + Assert.Contains("character=5:6:7:8:0:0", entry.Text); + Assert.Contains("social=9:10:11", entry.Text); + } + + [Fact] + public void J4GameplayOwnersHaveNoStaticMutableSessionState() + { + Type[] owners = + [ + typeof(RuntimeCommunicationState), + typeof(RuntimeInventoryState), + typeof(RuntimeCharacterState), + typeof(RuntimeCharacterOptionsState), + typeof(RuntimeMovementSkillState), + ]; + + foreach (Type owner in owners) + { + FieldInfo[] mutable = owner + .GetFields( + BindingFlags.Static + | BindingFlags.Public + | BindingFlags.NonPublic) + .Where(static field => !field.IsLiteral) + .ToArray(); + Assert.Empty(mutable); + } + } } diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs index f957df8c..7856bed2 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs @@ -1,4 +1,5 @@ using AcDream.Core.Spells; +using AcDream.Core.Player; using AcDream.Runtime.Gameplay; namespace AcDream.Runtime.Tests.Gameplay; @@ -152,4 +153,105 @@ public sealed class RuntimeCharacterStateTests 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); + } } diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCommunicationStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCommunicationStateTests.cs index 80689f23..f85806be 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCommunicationStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCommunicationStateTests.cs @@ -6,6 +6,44 @@ namespace AcDream.Runtime.Tests.Gameplay; public sealed class RuntimeCommunicationStateTests { + [Fact] + public void SocialViewBorrowsFriendsAndSquelchOwners() + { + using var state = new RuntimeCommunicationState(); + state.Friends.Apply(new AcDream.Core.Social.FriendsUpdate( + AcDream.Core.Social.FriendsUpdateType.Full, + [ + new AcDream.Core.Social.FriendEntry( + 0x50000001u, + "Friend", + Online: true, + AppearOffline: false, + Friends: [], + FriendOf: []), + ])); + state.Squelch.Replace(new AcDream.Core.Social.SquelchDatabase( + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["account"] = 1u, + }, + new Dictionary(), + new AcDream.Core.Social.SquelchInfo( + string.Empty, + false, + new HashSet { 3u }))); + + RuntimeSocialSnapshot snapshot = state.SocialView.Snapshot; + + Assert.Equal(1, snapshot.FriendCount); + Assert.Equal(1, snapshot.SquelchedAccountCount); + Assert.Equal(1, snapshot.GlobalSquelchTypeCount); + Assert.True(state.SocialView.TryGetFriend( + 0x50000001u, + out RuntimeFriendSnapshot friend)); + Assert.Equal("Friend", friend.Name); + Assert.True(friend.Online); + } + [Fact] public void OwnsOneExactCommunicationGraphAndPublishesCommittedEntries() { diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeInventoryStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeInventoryStateTests.cs index 70dfc6fb..8d7c6cce 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeInventoryStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeInventoryStateTests.cs @@ -6,6 +6,44 @@ namespace AcDream.Runtime.Tests.Gameplay; public sealed class RuntimeInventoryStateTests { + [Fact] + public void ViewBorrowsTransactionShortcutAndManaOwners() + { + using var entities = new RuntimeEntityObjectLifetime(); + using var inventory = new RuntimeInventoryState(entities); + inventory.Shortcuts.Replace( + [ + new AcDream.Core.Items.ShortcutEntry(3, 0x80000001u, 0u), + new AcDream.Core.Items.ShortcutEntry(4, 0u, 42u), + ]); + inventory.ItemMana.OnQueryItemManaResponse( + 0x80000001u, + 0.75f, + valid: true); + inventory.Transactions.IncrementBusyCount(); + + RuntimeInventoryStateSnapshot snapshot = inventory.View.Snapshot; + + Assert.Equal(2, snapshot.ShortcutCount); + Assert.Equal(1, snapshot.ItemManaCount); + Assert.Equal(1, snapshot.BusyCount); + Assert.False(snapshot.CanBeginRequest); + Assert.True(inventory.View.TryGetShortcut( + 3, + out RuntimeShortcutSnapshot itemShortcut)); + Assert.Equal( + new RuntimeShortcutSnapshot(3, 0x80000001u, 0u), + itemShortcut); + Assert.True(inventory.View.TryGetShortcut( + 4, + out RuntimeShortcutSnapshot spellShortcut)); + Assert.Equal(new RuntimeShortcutSnapshot(4, 0u, 42u), spellShortcut); + Assert.True(inventory.View.TryGetItemMana( + itemShortcut.ObjectId, + out float fraction)); + Assert.Equal(0.75f, fraction); + } + [Fact] public void OwnsOneGameplayGraphOverExactEntityObjectTable() { diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs index 7e4dd0ab..984bc907 100644 --- a/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs @@ -242,7 +242,6 @@ public sealed class LiveSessionEventRouterTests OnSkillsUpdated: null, OnConfirmationRequest: null, OnConfirmationDone: null, - OnCharacterOptions: null, ClientTime: () => 0d); private static LiveSocialSessionBindings NewSocialBindings(