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:
Erik 2026-07-26 09:48:51 +02:00
parent ce6fae7b38
commit 89e6b207f8
36 changed files with 1433 additions and 251 deletions

View file

@ -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() =>