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
|
|
@ -115,7 +115,7 @@ public sealed class RuntimeCharacterStateTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void DisposalRetriesObserverFailuresAndClearsBothOwners()
|
||||
public void DisposalReportsObserverFailuresAfterTerminalConvergence()
|
||||
{
|
||||
var state = new RuntimeCharacterState();
|
||||
state.Spellbook.OnSpellLearned(7u);
|
||||
|
|
@ -143,13 +143,13 @@ public sealed class RuntimeCharacterStateTests
|
|||
state.Dispose);
|
||||
|
||||
Assert.Equal(2, error.InnerExceptions.Count);
|
||||
Assert.False(state.IsDisposed);
|
||||
Assert.True(state.IsDisposed);
|
||||
Assert.False(state.Spellbook.Knows(7u));
|
||||
Assert.Null(state.LocalPlayer.Get(
|
||||
AcDream.Core.Player.LocalPlayerState.VitalKind.Health));
|
||||
Assert.True(state.CaptureOwnership().IsConverged);
|
||||
|
||||
state.Dispose();
|
||||
state.Dispose();
|
||||
|
||||
Assert.True(state.IsDisposed);
|
||||
}
|
||||
|
|
@ -254,4 +254,77 @@ public sealed class RuntimeCharacterStateTests
|
|||
Assert.True(first.View.Snapshot.SpellbookRevision > 0);
|
||||
Assert.Equal(0, second.View.Snapshot.SpellbookRevision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpellbookCommandsFollowRetailLocalAndOutboundOrder()
|
||||
{
|
||||
using var state = new RuntimeCharacterState();
|
||||
var order = new List<string>();
|
||||
state.Spellbook.SpellbookChanged += () => order.Add("local");
|
||||
state.Spellbook.DesiredComponentsChanged += () => order.Add("local");
|
||||
|
||||
Assert.True(state.TryAddFavorite(
|
||||
0,
|
||||
0,
|
||||
42u,
|
||||
() => order.Add("send")));
|
||||
Assert.Equal(["local", "send"], order);
|
||||
Assert.Equal([42u], state.Spellbook.GetFavorites(0));
|
||||
|
||||
order.Clear();
|
||||
state.SetSpellbookFilter(0x3FFEu, () => order.Add("send"));
|
||||
Assert.Equal(["local", "send"], order);
|
||||
Assert.Equal(0x3FFEu, state.Spellbook.SpellbookFilters);
|
||||
|
||||
order.Clear();
|
||||
Assert.True(state.TrySetDesiredComponent(
|
||||
0x68000001u,
|
||||
0u,
|
||||
() => order.Add("send")));
|
||||
Assert.Equal(["send", "local"], order);
|
||||
Assert.True(state.Spellbook.DesiredComponents.ContainsKey(
|
||||
0x68000001u));
|
||||
Assert.Equal(0u, state.Spellbook.DesiredComponents[0x68000001u]);
|
||||
|
||||
order.Clear();
|
||||
state.ClearDesiredComponents(() => order.Add("send"));
|
||||
Assert.Equal(["send", "local"], order);
|
||||
Assert.Empty(state.Spellbook.DesiredComponents);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => state.TrySetDesiredComponent(
|
||||
0x68000002u,
|
||||
10u,
|
||||
() => throw new InvalidOperationException("transport")));
|
||||
Assert.Equal(10u, state.Spellbook.DesiredComponents[0x68000002u]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidSpellbookCommandsDoNotPublishOrMutate()
|
||||
{
|
||||
using var state = new RuntimeCharacterState();
|
||||
int sends = 0;
|
||||
|
||||
Assert.False(state.TryAddFavorite(
|
||||
8,
|
||||
0,
|
||||
42u,
|
||||
() => sends++));
|
||||
Assert.False(state.TryRemoveFavorite(
|
||||
-1,
|
||||
42u,
|
||||
() => sends++));
|
||||
Assert.False(state.TrySetDesiredComponent(
|
||||
0u,
|
||||
1u,
|
||||
() => sends++));
|
||||
Assert.False(state.TrySetDesiredComponent(
|
||||
1u,
|
||||
5001u,
|
||||
() => sends++));
|
||||
|
||||
Assert.Equal(0, sends);
|
||||
Assert.Empty(state.Spellbook.GetFavorites(0));
|
||||
Assert.Empty(state.Spellbook.DesiredComponents);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,143 @@
|
|||
using AcDream.Core.Chat;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Social;
|
||||
using AcDream.Runtime.Entities;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
|
||||
namespace AcDream.Runtime.Tests.Gameplay;
|
||||
|
||||
public sealed class RuntimeGameplayOwnershipTests
|
||||
{
|
||||
[Fact]
|
||||
public void CombinedLedgerConvergesEveryJ4OwnerAndSubscription()
|
||||
{
|
||||
using var entities = new RuntimeEntityObjectLifetime();
|
||||
var inventory = new RuntimeInventoryState(entities);
|
||||
var character = new RuntimeCharacterState();
|
||||
var communication = new RuntimeCommunicationState();
|
||||
inventory.Shortcuts.Changed += static () => { };
|
||||
inventory.Shortcuts.Load([new ShortcutEntry(1, 2u, 3u)]);
|
||||
inventory.ItemMana.OnQueryItemManaResponse(2u, 0.5f, true);
|
||||
inventory.ExternalContainers.RequestOpen(3u);
|
||||
inventory.ExternalContainers.ApplyViewContents(3u);
|
||||
inventory.Transactions.IncrementBusyCount();
|
||||
|
||||
character.Spellbook.OnSpellLearned(4u);
|
||||
character.Spellbook.SetFavorite(0, 0, 4u);
|
||||
character.Spellbook.SetDesiredComponent(5u, 6u);
|
||||
character.LocalPlayer.OnVitalUpdate(7u, 1u, 2u, 3u, 4u);
|
||||
character.LocalPlayer.OnAttributeUpdate(1u, 2u, 3u, 4u);
|
||||
character.LocalPlayer.OnSkillUpdate(
|
||||
6u, 1u, 2u, 3u, 4u, 5u, 6d, 7u);
|
||||
character.Options.Replace(1u, 2u);
|
||||
character.MovementSkills.Update(3, 4);
|
||||
|
||||
communication.Chat.OnTellReceived(
|
||||
"Sender",
|
||||
"hello",
|
||||
0x50000001u);
|
||||
communication.Chat.OnSelfSent(
|
||||
ChatKind.Tell,
|
||||
"reply",
|
||||
"Recipient");
|
||||
communication.TurbineChat.OnChannelsReceived(
|
||||
1u, 2u, 3u, 4u, 5u, 6u, 7u, 8u, 9u, 10u);
|
||||
communication.Friends.Apply(new FriendsUpdate(
|
||||
FriendsUpdateType.Full,
|
||||
[new FriendEntry(1u, "Friend", true, false, [], [])]));
|
||||
communication.Squelch.Replace(new SquelchDatabase(
|
||||
new Dictionary<string, uint> { ["account"] = 1u },
|
||||
new Dictionary<uint, SquelchInfo>
|
||||
{
|
||||
[2u] = new SquelchInfo(
|
||||
"Character",
|
||||
false,
|
||||
new HashSet<uint> { 3u }),
|
||||
},
|
||||
new SquelchInfo(
|
||||
string.Empty,
|
||||
false,
|
||||
new HashSet<uint> { 4u })));
|
||||
IDisposable subscription =
|
||||
communication.Events.Subscribe(new NoopObserver());
|
||||
|
||||
RuntimeGameplayOwnershipSnapshot populated =
|
||||
RuntimeGameplayOwnership.Capture(
|
||||
inventory,
|
||||
character,
|
||||
communication);
|
||||
|
||||
Assert.False(populated.IsConverged);
|
||||
Assert.Equal(1, populated.Inventory.ShortcutCount);
|
||||
Assert.Equal(1, populated.Character.LearnedSpellCount);
|
||||
Assert.Equal(1, populated.Communication.StreamSubscriberCount);
|
||||
Assert.True(populated.Communication.HasReplyTarget);
|
||||
Assert.True(populated.Communication.HasRetellTarget);
|
||||
|
||||
communication.Dispose();
|
||||
character.Dispose();
|
||||
inventory.Dispose();
|
||||
subscription.Dispose();
|
||||
|
||||
RuntimeGameplayOwnershipSnapshot retired =
|
||||
RuntimeGameplayOwnership.Capture(
|
||||
inventory,
|
||||
character,
|
||||
communication);
|
||||
|
||||
Assert.True(retired.IsConverged);
|
||||
Assert.Equal(0, retired.Inventory.ShortcutSubscriberCount);
|
||||
Assert.False(retired.Character.InternalSubscriptionsAttached);
|
||||
Assert.Equal(0, retired.Communication.StreamSubscriberCount);
|
||||
Assert.Equal(0, retired.Communication.PendingDispatchCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BorrowerFailuresCannotStrandAnyJ4OwnerDuringTerminalDisposal()
|
||||
{
|
||||
using var entities = new RuntimeEntityObjectLifetime();
|
||||
var inventory = new RuntimeInventoryState(entities);
|
||||
var character = new RuntimeCharacterState();
|
||||
var communication = new RuntimeCommunicationState();
|
||||
|
||||
inventory.ExternalContainers.RequestOpen(0x70000001u);
|
||||
inventory.ExternalContainers.ApplyViewContents(0x70000001u);
|
||||
inventory.ExternalContainers.Changed += static _ =>
|
||||
throw new InvalidOperationException("inventory observer");
|
||||
|
||||
character.Spellbook.OnSpellLearned(42u);
|
||||
character.LocalPlayer.OnVitalUpdate(7u, 1u, 2u, 3u, 4u);
|
||||
character.Spellbook.SpellbookChanged += static () =>
|
||||
throw new InvalidOperationException("spellbook observer");
|
||||
character.LocalPlayer.CharacterChanged += static () =>
|
||||
throw new InvalidOperationException("character observer");
|
||||
|
||||
_ = communication.Events.Subscribe(new ThrowingObserver());
|
||||
communication.Chat.OnSystemMessage("failure-isolated event", 0u);
|
||||
Assert.Equal(1, communication.DispatchFailureCount);
|
||||
|
||||
Assert.Throws<AggregateException>(inventory.Dispose);
|
||||
Assert.Throws<AggregateException>(character.Dispose);
|
||||
communication.Dispose();
|
||||
|
||||
RuntimeGameplayOwnershipSnapshot retired =
|
||||
RuntimeGameplayOwnership.Capture(
|
||||
inventory,
|
||||
character,
|
||||
communication);
|
||||
|
||||
Assert.True(retired.IsConverged);
|
||||
Assert.Equal(1, retired.Communication.DispatchFailureCount);
|
||||
}
|
||||
|
||||
private sealed class NoopObserver : IRuntimeCommunicationObserver
|
||||
{
|
||||
public void OnChat(in RuntimeCommunicationEvent delta) { }
|
||||
}
|
||||
|
||||
private sealed class ThrowingObserver : IRuntimeCommunicationObserver
|
||||
{
|
||||
public void OnChat(in RuntimeCommunicationEvent delta) =>
|
||||
throw new InvalidOperationException("communication observer");
|
||||
}
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ public sealed class RuntimeInventoryStateTests
|
|||
{
|
||||
using var entities = new RuntimeEntityObjectLifetime();
|
||||
using var inventory = new RuntimeInventoryState(entities);
|
||||
inventory.Shortcuts.Replace(
|
||||
inventory.Shortcuts.Load(
|
||||
[
|
||||
new AcDream.Core.Items.ShortcutEntry(3, 0x80000001u, 0u),
|
||||
new AcDream.Core.Items.ShortcutEntry(4, 0u, 42u),
|
||||
|
|
@ -55,12 +55,12 @@ public sealed class RuntimeInventoryStateTests
|
|||
inventory.ExternalContainers.RequestOpen(0x70000001u);
|
||||
inventory.ExternalContainers.ApplyViewContents(0x70000001u);
|
||||
inventory.ItemMana.OnQueryItemManaResponse(0x50000001u, 0.75f, true);
|
||||
inventory.Shortcuts.Replace(shortcuts);
|
||||
inventory.Shortcuts.Load(shortcuts);
|
||||
|
||||
Assert.Same(entities.Objects, inventory.Objects);
|
||||
Assert.Equal(0x70000001u, inventory.ExternalContainers.CurrentContainerId);
|
||||
Assert.Equal(0.75f, inventory.ItemMana.GetManaPercent(0x50000001u));
|
||||
Assert.Same(shortcuts, inventory.Shortcuts.Items);
|
||||
Assert.Equal(shortcuts, inventory.Shortcuts.Items);
|
||||
Assert.Equal(1, inventory.Shortcuts.Revision);
|
||||
}
|
||||
|
||||
|
|
@ -72,7 +72,7 @@ public sealed class RuntimeInventoryStateTests
|
|||
inventory.ExternalContainers.RequestOpen(0x70000001u);
|
||||
inventory.ExternalContainers.ApplyViewContents(0x70000001u);
|
||||
inventory.ItemMana.OnQueryItemManaResponse(0x50000001u, 0.75f, true);
|
||||
inventory.Shortcuts.Replace([new ShortcutEntry(1, 2u, 3u)]);
|
||||
inventory.Shortcuts.Load([new ShortcutEntry(1, 2u, 3u)]);
|
||||
inventory.Transactions.IncrementBusyCount();
|
||||
|
||||
inventory.ResetExternalContainer();
|
||||
|
|
@ -98,7 +98,7 @@ public sealed class RuntimeInventoryStateTests
|
|||
using var first = new RuntimeInventoryState(firstEntities);
|
||||
using var second = new RuntimeInventoryState(secondEntities);
|
||||
|
||||
first.Shortcuts.Replace([new ShortcutEntry(1, 2u, 3u)]);
|
||||
first.Shortcuts.Load([new ShortcutEntry(1, 2u, 3u)]);
|
||||
first.Transactions.IncrementBusyCount();
|
||||
|
||||
Assert.NotSame(first.Objects, second.Objects);
|
||||
|
|
@ -116,7 +116,7 @@ public sealed class RuntimeInventoryStateTests
|
|||
inventory.ExternalContainers.RequestOpen(0x70000001u);
|
||||
inventory.ExternalContainers.ApplyViewContents(0x70000001u);
|
||||
inventory.ItemMana.OnQueryItemManaResponse(0x50000001u, 0.5f, true);
|
||||
inventory.Shortcuts.Replace([new ShortcutEntry(1, 2u, 3u)]);
|
||||
inventory.Shortcuts.Load([new ShortcutEntry(1, 2u, 3u)]);
|
||||
|
||||
inventory.Dispose();
|
||||
inventory.Dispose();
|
||||
|
|
@ -129,7 +129,7 @@ public sealed class RuntimeInventoryStateTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void DisposalRetriesATransientOwnerObserverFailureToConvergence()
|
||||
public void DisposalFailureIsReportedAfterTerminalOwnerConvergence()
|
||||
{
|
||||
using var entities = new RuntimeEntityObjectLifetime();
|
||||
var inventory = new RuntimeInventoryState(entities);
|
||||
|
|
@ -146,12 +146,72 @@ public sealed class RuntimeInventoryStateTests
|
|||
};
|
||||
|
||||
Assert.Throws<AggregateException>(inventory.Dispose);
|
||||
Assert.False(inventory.IsDisposed);
|
||||
Assert.True(inventory.IsDisposed);
|
||||
Assert.True(inventory.Transactions.IsDisposed);
|
||||
inventory.Dispose();
|
||||
|
||||
Assert.True(inventory.CaptureOwnership().IsConverged);
|
||||
Assert.Equal(0u, inventory.ExternalContainers.CurrentContainerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShortcutCommandsUseRetailSendThenLocalOrderOnTheExactOwner()
|
||||
{
|
||||
using var entities = new RuntimeEntityObjectLifetime();
|
||||
using var inventory = new RuntimeInventoryState(entities);
|
||||
var order = new List<string>();
|
||||
inventory.Shortcuts.Changed += () => order.Add("local");
|
||||
var entry = new ShortcutEntry(3, 0x80000001u, 0u);
|
||||
|
||||
Assert.True(inventory.TryAddShortcut(
|
||||
entry,
|
||||
() =>
|
||||
{
|
||||
Assert.Null(inventory.Shortcuts.GetEntry(3));
|
||||
order.Add("send");
|
||||
}));
|
||||
Assert.Equal(["send", "local"], order);
|
||||
Assert.Equal(entry, inventory.Shortcuts.GetEntry(3));
|
||||
|
||||
order.Clear();
|
||||
Assert.True(inventory.TryRemoveShortcut(
|
||||
3,
|
||||
() =>
|
||||
{
|
||||
Assert.Equal(entry, inventory.Shortcuts.GetEntry(3));
|
||||
order.Add("send");
|
||||
}));
|
||||
Assert.Equal(["send", "local"], order);
|
||||
Assert.Null(inventory.Shortcuts.GetEntry(3));
|
||||
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => inventory.TryAddShortcut(
|
||||
entry,
|
||||
() => throw new InvalidOperationException("transport")));
|
||||
Assert.Equal(entry, inventory.Shortcuts.GetEntry(3));
|
||||
|
||||
int sends = 0;
|
||||
Assert.False(inventory.TryAddShortcut(
|
||||
new ShortcutEntry(ShortcutStore.SlotCount, 1u, 0u),
|
||||
() => sends++));
|
||||
Assert.False(inventory.TryRemoveShortcut(-1, () => sends++));
|
||||
Assert.Equal(0, sends);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisposalRetiresShortcutObserversAndTransactionSubscriptions()
|
||||
{
|
||||
using var entities = new RuntimeEntityObjectLifetime();
|
||||
var inventory = new RuntimeInventoryState(entities);
|
||||
inventory.Shortcuts.Changed += static () => { };
|
||||
inventory.Shortcuts.Load([new ShortcutEntry(1, 2u, 3u)]);
|
||||
|
||||
inventory.Dispose();
|
||||
|
||||
Assert.True(inventory.IsDisposed);
|
||||
Assert.Equal(0u, inventory.ExternalContainers.CurrentContainerId);
|
||||
Assert.True(inventory.Shortcuts.IsDisposed);
|
||||
Assert.True(inventory.Transactions.IsDisposed);
|
||||
Assert.Equal(0, inventory.Shortcuts.SubscriberCount);
|
||||
Assert.Empty(inventory.Shortcuts.Items);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue