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() =>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue