refactor(runtime): close canonical gameplay ownership
Unify the toolbar shortcut manager with Runtime inventory state, route retail-ordered shortcut and spellbook command effects through the canonical owners, and make retained controllers borrow those exact instances. Remove the item-interaction transaction fallback and add graphical/no-window parity plus failure-safe terminal ownership-ledger coverage. Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
parent
ce6fae7b38
commit
89e6b207f8
36 changed files with 1433 additions and 251 deletions
|
|
@ -1,9 +1,41 @@
|
|||
using AcDream.Core.Player;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Spells;
|
||||
using AcDream.Core.Items;
|
||||
|
||||
namespace AcDream.Runtime.Gameplay;
|
||||
|
||||
public readonly record struct RuntimeCharacterOwnershipSnapshot(
|
||||
bool IsDisposed,
|
||||
bool InternalSubscriptionsAttached,
|
||||
int LearnedSpellCount,
|
||||
int ActiveEnchantmentCount,
|
||||
int DesiredComponentCount,
|
||||
int FavoriteSpellCount,
|
||||
int VitalCount,
|
||||
int AttributeCount,
|
||||
int SkillCount,
|
||||
int PositionCount,
|
||||
int PropertyCount,
|
||||
bool OptionsAreDefaults,
|
||||
bool MovementSkillsAreReset)
|
||||
{
|
||||
public bool IsConverged =>
|
||||
IsDisposed
|
||||
&& !InternalSubscriptionsAttached
|
||||
&& LearnedSpellCount == 0
|
||||
&& ActiveEnchantmentCount == 0
|
||||
&& DesiredComponentCount == 0
|
||||
&& FavoriteSpellCount == 0
|
||||
&& VitalCount == 0
|
||||
&& AttributeCount == 0
|
||||
&& SkillCount == 0
|
||||
&& PositionCount == 0
|
||||
&& PropertyCount == 0
|
||||
&& OptionsAreDefaults
|
||||
&& MovementSkillsAreReset;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Canonical presentation-independent owner for the local character's magic
|
||||
/// and player-sheet state. The two objects form one lifetime group because
|
||||
|
|
@ -14,6 +46,7 @@ public sealed class RuntimeCharacterState : IDisposable
|
|||
private bool _disposed;
|
||||
private long _characterRevision;
|
||||
private long _spellbookRevision;
|
||||
private bool _internalSubscriptionsAttached;
|
||||
|
||||
public RuntimeCharacterState(SpellTable? spellTable = null)
|
||||
{
|
||||
|
|
@ -26,6 +59,7 @@ public sealed class RuntimeCharacterState : IDisposable
|
|||
LocalPlayer.Changed += OnVitalChanged;
|
||||
LocalPlayer.AttributeChanged += OnAttributeChanged;
|
||||
LocalPlayer.CharacterChanged += OnCharacterChanged;
|
||||
_internalSubscriptionsAttached = true;
|
||||
}
|
||||
|
||||
public Spellbook Spellbook { get; }
|
||||
|
|
@ -35,6 +69,57 @@ public sealed class RuntimeCharacterState : IDisposable
|
|||
public IRuntimeCharacterView View { get; }
|
||||
public bool IsDisposed => _disposed;
|
||||
|
||||
public RuntimeCharacterOwnershipSnapshot CaptureOwnership()
|
||||
{
|
||||
int favoriteCount = 0;
|
||||
for (int tab = 0; tab < 8; tab++)
|
||||
favoriteCount += Spellbook.GetFavorites(tab).Count;
|
||||
|
||||
int vitalCount = 0;
|
||||
foreach (LocalPlayerState.VitalKind kind
|
||||
in Enum.GetValues<LocalPlayerState.VitalKind>())
|
||||
{
|
||||
if (LocalPlayer.Get(kind) is not null)
|
||||
vitalCount++;
|
||||
}
|
||||
|
||||
int attributeCount = 0;
|
||||
foreach (LocalPlayerState.AttributeKind kind
|
||||
in Enum.GetValues<LocalPlayerState.AttributeKind>())
|
||||
{
|
||||
if (LocalPlayer.GetAttribute(kind) is not null)
|
||||
attributeCount++;
|
||||
}
|
||||
|
||||
PropertyBundle properties = LocalPlayer.Properties;
|
||||
int propertyCount =
|
||||
properties.Bools.Count
|
||||
+ properties.Ints.Count
|
||||
+ properties.Int64s.Count
|
||||
+ properties.Floats.Count
|
||||
+ properties.Strings.Count
|
||||
+ properties.DataIds.Count
|
||||
+ properties.InstanceIds.Count;
|
||||
RuntimeCharacterOptionsSnapshot options = Options.Snapshot;
|
||||
return new RuntimeCharacterOwnershipSnapshot(
|
||||
_disposed,
|
||||
_internalSubscriptionsAttached,
|
||||
Spellbook.LearnedCount,
|
||||
Spellbook.ActiveCount,
|
||||
Spellbook.DesiredComponents.Count,
|
||||
favoriteCount,
|
||||
vitalCount,
|
||||
attributeCount,
|
||||
LocalPlayer.Skills.Count,
|
||||
LocalPlayer.Positions.Count,
|
||||
propertyCount,
|
||||
options.Options1 == RuntimeCharacterOptionsState.DefaultOptions1
|
||||
&& options.Options2
|
||||
== RuntimeCharacterOptionsState.DefaultOptions2,
|
||||
MovementSkills.RunSkill == -1
|
||||
&& MovementSkills.JumpSkill == -1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Installs immutable DAT metadata without transferring its ownership to
|
||||
/// Runtime. The content host may install one table after portal.dat opens.
|
||||
|
|
@ -57,6 +142,93 @@ public sealed class RuntimeCharacterState : IDisposable
|
|||
LocalPlayer.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply retail's local favorite insertion before sending the matching
|
||||
/// character event.
|
||||
/// </summary>
|
||||
public bool TryAddFavorite(
|
||||
int tabIndex,
|
||||
int position,
|
||||
uint spellId,
|
||||
Action publishOutbound)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ArgumentNullException.ThrowIfNull(publishOutbound);
|
||||
if ((uint)tabIndex >= 8u || position < 0 || spellId == 0u)
|
||||
return false;
|
||||
Spellbook.SetFavorite(tabIndex, position, spellId);
|
||||
publishOutbound();
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryRemoveFavorite(
|
||||
int tabIndex,
|
||||
uint spellId,
|
||||
Action publishOutbound)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ArgumentNullException.ThrowIfNull(publishOutbound);
|
||||
if ((uint)tabIndex >= 8u || spellId == 0u)
|
||||
return false;
|
||||
Spellbook.RemoveFavorite(tabIndex, spellId);
|
||||
publishOutbound();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply and publish a spellbook filter only when it differs, matching
|
||||
/// <c>gmSpellbookUI::UpdateFilter @ 0x0048B5E0</c>.
|
||||
/// </summary>
|
||||
public void SetSpellbookFilter(
|
||||
uint filters,
|
||||
Action publishOutbound)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ArgumentNullException.ThrowIfNull(publishOutbound);
|
||||
if (Spellbook.SpellbookFilters == filters)
|
||||
return;
|
||||
Spellbook.SetSpellbookFilters(filters);
|
||||
publishOutbound();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail publishes the desired-component event before changing its local
|
||||
/// PlayerModule table.
|
||||
/// </summary>
|
||||
public bool TrySetDesiredComponent(
|
||||
uint componentId,
|
||||
uint amount,
|
||||
Action publishOutbound)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ArgumentNullException.ThrowIfNull(publishOutbound);
|
||||
if (componentId == 0u || amount > 5000u)
|
||||
return false;
|
||||
try
|
||||
{
|
||||
publishOutbound();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Spellbook.SetDesiredComponent(componentId, amount);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public void ClearDesiredComponents(Action publishOutbound)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ArgumentNullException.ThrowIfNull(publishOutbound);
|
||||
try
|
||||
{
|
||||
publishOutbound();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Spellbook.ClearDesiredComponents();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears both coupled owners while retaining every failed suffix for a
|
||||
/// retry. State mutation happens before the existing synchronous
|
||||
|
|
@ -82,12 +254,32 @@ public sealed class RuntimeCharacterState : IDisposable
|
|||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
ResetSession();
|
||||
Spellbook.StateChanged -= OnSpellbookChanged;
|
||||
LocalPlayer.Changed -= OnVitalChanged;
|
||||
LocalPlayer.AttributeChanged -= OnAttributeChanged;
|
||||
LocalPlayer.CharacterChanged -= OnCharacterChanged;
|
||||
_disposed = true;
|
||||
List<Exception>? failures = null;
|
||||
try
|
||||
{
|
||||
// Do not call ResetSession as one opaque step here. Terminal
|
||||
// disposal must run every suffix even when an external UI observer
|
||||
// throws from one Core owner's synchronous clear notification.
|
||||
Try(Spellbook.Clear, ref failures);
|
||||
Try(LocalPlayer.Clear, ref failures);
|
||||
Try(Options.ResetSession, ref failures);
|
||||
Try(MovementSkills.ResetSession, ref failures);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Spellbook.StateChanged -= OnSpellbookChanged;
|
||||
LocalPlayer.Changed -= OnVitalChanged;
|
||||
LocalPlayer.AttributeChanged -= OnAttributeChanged;
|
||||
LocalPlayer.CharacterChanged -= OnCharacterChanged;
|
||||
_internalSubscriptionsAttached = false;
|
||||
_disposed = true;
|
||||
}
|
||||
if (failures is not null)
|
||||
{
|
||||
throw new AggregateException(
|
||||
"Runtime character state did not converge during disposal.",
|
||||
failures);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSpellbookChanged() =>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,36 @@ public interface IRuntimeCommunicationEventSource
|
|||
IDisposable Subscribe(IRuntimeCommunicationObserver observer);
|
||||
}
|
||||
|
||||
public readonly record struct RuntimeCommunicationOwnershipSnapshot(
|
||||
bool IsDisposed,
|
||||
bool CommandTargetsDisposed,
|
||||
int StreamSubscriberCount,
|
||||
int PendingDispatchCount,
|
||||
bool IsDispatching,
|
||||
int FriendCount,
|
||||
int SquelchAccountCount,
|
||||
int SquelchCharacterCount,
|
||||
int SquelchGlobalTypeCount,
|
||||
int NegotiatedRoomCount,
|
||||
bool HasReplyTarget,
|
||||
bool HasRetellTarget,
|
||||
long DispatchFailureCount)
|
||||
{
|
||||
public bool IsConverged =>
|
||||
IsDisposed
|
||||
&& CommandTargetsDisposed
|
||||
&& StreamSubscriberCount == 0
|
||||
&& PendingDispatchCount == 0
|
||||
&& !IsDispatching
|
||||
&& FriendCount == 0
|
||||
&& SquelchAccountCount == 0
|
||||
&& SquelchCharacterCount == 0
|
||||
&& SquelchGlobalTypeCount == 0
|
||||
&& NegotiatedRoomCount == 0
|
||||
&& !HasReplyTarget
|
||||
&& !HasRetellTarget;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Canonical presentation-independent owner for communication and social
|
||||
/// state. Graphical, headless, plugin, and bot hosts borrow these exact
|
||||
|
|
@ -59,6 +89,25 @@ public sealed class RuntimeCommunicationState : IDisposable
|
|||
public long DispatchFailureCount => _events.DispatchFailureCount;
|
||||
public Exception? LastDispatchFailure => _events.LastDispatchFailure;
|
||||
|
||||
public RuntimeCommunicationOwnershipSnapshot CaptureOwnership()
|
||||
{
|
||||
SquelchDatabase squelch = Squelch.Snapshot();
|
||||
return new RuntimeCommunicationOwnershipSnapshot(
|
||||
_disposed,
|
||||
CommandTargets.IsDisposed,
|
||||
SubscriberCount,
|
||||
PendingDispatchCount,
|
||||
IsDispatching,
|
||||
Friends.Count,
|
||||
squelch.Accounts.Count,
|
||||
squelch.Characters.Count,
|
||||
squelch.Global.MessageTypes.Count,
|
||||
CountRooms(TurbineChat),
|
||||
CommandTargets.LastIncomingTellSender is not null,
|
||||
CommandTargets.LastOutgoingTellTarget is not null,
|
||||
DispatchFailureCount);
|
||||
}
|
||||
|
||||
public void ResetCommandTargets() => CommandTargets.ResetSession();
|
||||
public void ResetChatIdentity() => Chat.ResetSessionIdentity();
|
||||
public void ResetNegotiatedChannels() => TurbineChat.Reset();
|
||||
|
|
@ -126,18 +175,19 @@ public sealed class RuntimeCommunicationState : IDisposable
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
34
src/AcDream.Runtime/Gameplay/RuntimeGameplayOwnership.cs
Normal file
34
src/AcDream.Runtime/Gameplay/RuntimeGameplayOwnership.cs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
namespace AcDream.Runtime.Gameplay;
|
||||
|
||||
/// <summary>
|
||||
/// One allocation-free ledger over the complete J4 gameplay-state lifetime
|
||||
/// group. It contains no state of its own; graphical and no-window hosts
|
||||
/// capture the same three canonical owners.
|
||||
/// </summary>
|
||||
public readonly record struct RuntimeGameplayOwnershipSnapshot(
|
||||
RuntimeInventoryOwnershipSnapshot Inventory,
|
||||
RuntimeCharacterOwnershipSnapshot Character,
|
||||
RuntimeCommunicationOwnershipSnapshot Communication)
|
||||
{
|
||||
public bool IsConverged =>
|
||||
Inventory.IsConverged
|
||||
&& Character.IsConverged
|
||||
&& Communication.IsConverged;
|
||||
}
|
||||
|
||||
public static class RuntimeGameplayOwnership
|
||||
{
|
||||
public static RuntimeGameplayOwnershipSnapshot Capture(
|
||||
RuntimeInventoryState inventory,
|
||||
RuntimeCharacterState character,
|
||||
RuntimeCommunicationState communication)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(inventory);
|
||||
ArgumentNullException.ThrowIfNull(character);
|
||||
ArgumentNullException.ThrowIfNull(communication);
|
||||
return new RuntimeGameplayOwnershipSnapshot(
|
||||
inventory.CaptureOwnership(),
|
||||
character.CaptureOwnership(),
|
||||
communication.CaptureOwnership());
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,33 @@ using AcDream.Runtime.Entities;
|
|||
|
||||
namespace AcDream.Runtime.Gameplay;
|
||||
|
||||
public readonly record struct RuntimeInventoryOwnershipSnapshot(
|
||||
bool IsDisposed,
|
||||
bool TransactionsDisposed,
|
||||
bool ShortcutsDisposed,
|
||||
int BusyCount,
|
||||
bool HasPendingRequest,
|
||||
uint RequestedContainerId,
|
||||
uint CurrentContainerId,
|
||||
int ItemManaCount,
|
||||
int ShortcutCount,
|
||||
int ShortcutSubscriberCount,
|
||||
long ShortcutDispatchFailureCount,
|
||||
long TransactionDispatchFailureCount)
|
||||
{
|
||||
public bool IsConverged =>
|
||||
IsDisposed
|
||||
&& TransactionsDisposed
|
||||
&& ShortcutsDisposed
|
||||
&& BusyCount == 0
|
||||
&& !HasPendingRequest
|
||||
&& RequestedContainerId == 0u
|
||||
&& CurrentContainerId == 0u
|
||||
&& ItemManaCount == 0
|
||||
&& ShortcutCount == 0
|
||||
&& ShortcutSubscriberCount == 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Canonical presentation-independent owner for inventory gameplay state.
|
||||
/// The object collection is borrowed directly from J3's entity/object
|
||||
|
|
@ -19,7 +46,7 @@ public sealed class RuntimeInventoryState : IDisposable
|
|||
?? throw new ArgumentNullException(nameof(entityObjects));
|
||||
ExternalContainers = new ExternalContainerState();
|
||||
ItemMana = new ItemManaState();
|
||||
Shortcuts = new ShortcutState();
|
||||
Shortcuts = new ShortcutStore();
|
||||
Transactions = new InventoryTransactionState(_entityObjects.Objects);
|
||||
View = new InventoryStateView(this);
|
||||
}
|
||||
|
|
@ -27,11 +54,25 @@ public sealed class RuntimeInventoryState : IDisposable
|
|||
public ClientObjectTable Objects => _entityObjects.Objects;
|
||||
public ExternalContainerState ExternalContainers { get; }
|
||||
public ItemManaState ItemMana { get; }
|
||||
public ShortcutState Shortcuts { get; }
|
||||
public ShortcutStore Shortcuts { get; }
|
||||
public InventoryTransactionState Transactions { get; }
|
||||
public IRuntimeInventoryStateView View { get; }
|
||||
public bool IsDisposed => _disposed;
|
||||
|
||||
public RuntimeInventoryOwnershipSnapshot CaptureOwnership() => new(
|
||||
_disposed,
|
||||
Transactions.IsDisposed,
|
||||
Shortcuts.IsDisposed,
|
||||
Transactions.BusyCount,
|
||||
Transactions.HasPendingRequest,
|
||||
ExternalContainers.RequestedContainerId,
|
||||
ExternalContainers.CurrentContainerId,
|
||||
ItemMana.Count,
|
||||
Shortcuts.Count,
|
||||
Shortcuts.SubscriberCount,
|
||||
Shortcuts.DispatchFailureCount,
|
||||
Transactions.DispatchFailureCount);
|
||||
|
||||
public void ResetExternalContainer() => ExternalContainers.Reset();
|
||||
public void ResetTransactions() => Transactions.ResetSession();
|
||||
public void ResetItemMana() => ItemMana.Clear();
|
||||
|
|
@ -41,20 +82,82 @@ public sealed class RuntimeInventoryState : IDisposable
|
|||
Shortcuts.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send and apply one retail toolbar add on the canonical shortcut
|
||||
/// manager. Retail emits <c>Event_AddShortCut</c> before mutating
|
||||
/// <c>PlayerModule</c>.
|
||||
/// </summary>
|
||||
public bool TryAddShortcut(
|
||||
ShortcutEntry entry,
|
||||
Action publishOutbound)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ArgumentNullException.ThrowIfNull(publishOutbound);
|
||||
if ((uint)entry.Index >= ShortcutStore.SlotCount
|
||||
|| (entry.ObjectId == 0u && entry.SpellId == 0u))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
publishOutbound();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Shortcuts.Set(entry);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send and apply one retail toolbar removal on the canonical shortcut
|
||||
/// manager.
|
||||
/// </summary>
|
||||
public bool TryRemoveShortcut(
|
||||
int index,
|
||||
Action publishOutbound)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ArgumentNullException.ThrowIfNull(publishOutbound);
|
||||
if ((uint)index >= ShortcutStore.SlotCount)
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
publishOutbound();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Shortcuts.Remove(index);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
List<Exception>? failures = null;
|
||||
Try(() => ExternalContainers.Reset(), ref failures);
|
||||
Try(ItemMana.Clear, ref failures);
|
||||
Try(Shortcuts.Clear, ref failures);
|
||||
Try(Transactions.Dispose, ref failures);
|
||||
try
|
||||
{
|
||||
Try(() => ExternalContainers.Reset(), ref failures);
|
||||
Try(ItemMana.Clear, ref failures);
|
||||
Try(Shortcuts.Dispose, ref failures);
|
||||
Try(Transactions.Dispose, ref failures);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Disposal is terminal even when a borrowed presentation observer
|
||||
// fails. Every owned leaf above mutates before dispatching, so the
|
||||
// full suffix is safe to run and the Runtime root must never remain
|
||||
// half-alive over already-disposed children.
|
||||
_disposed = true;
|
||||
}
|
||||
if (failures is not null)
|
||||
throw new AggregateException(
|
||||
"Runtime inventory state did not converge during disposal.",
|
||||
failures);
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private static void Try(Action action, ref List<Exception>? failures)
|
||||
|
|
@ -93,7 +196,7 @@ public sealed class RuntimeInventoryState : IDisposable
|
|||
owner.Transactions.BusyCount,
|
||||
owner.Transactions.CanBeginRequest,
|
||||
pending,
|
||||
owner.Shortcuts.Items.Count,
|
||||
owner.Shortcuts.Count,
|
||||
owner.Shortcuts.Revision,
|
||||
owner.ItemMana.Count,
|
||||
owner.ItemMana.Revision);
|
||||
|
|
@ -124,22 +227,3 @@ public sealed class RuntimeInventoryState : IDisposable
|
|||
owner.ItemMana.TryGetManaPercent(objectId, out fraction);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ShortcutState
|
||||
{
|
||||
private IReadOnlyList<ShortcutEntry> _items = Array.Empty<ShortcutEntry>();
|
||||
private long _revision;
|
||||
|
||||
public IReadOnlyList<ShortcutEntry> Items => Volatile.Read(ref _items);
|
||||
public long Revision => Interlocked.Read(ref _revision);
|
||||
|
||||
public void Replace(IReadOnlyList<ShortcutEntry>? items)
|
||||
{
|
||||
Volatile.Write(
|
||||
ref _items,
|
||||
items ?? Array.Empty<ShortcutEntry>());
|
||||
Interlocked.Increment(ref _revision);
|
||||
}
|
||||
|
||||
public void Clear() => Replace(Array.Empty<ShortcutEntry>());
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue