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>
516 lines
16 KiB
C#
516 lines
16 KiB
C#
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
|
|
/// vital maxima read active enchantments from this exact spellbook.
|
|
/// </summary>
|
|
public sealed class RuntimeCharacterState : IDisposable
|
|
{
|
|
private bool _disposed;
|
|
private long _characterRevision;
|
|
private long _spellbookRevision;
|
|
private bool _internalSubscriptionsAttached;
|
|
|
|
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;
|
|
_internalSubscriptionsAttached = true;
|
|
}
|
|
|
|
public Spellbook Spellbook { get; }
|
|
public LocalPlayerState LocalPlayer { get; }
|
|
public RuntimeCharacterOptionsState Options { get; }
|
|
public RuntimeMovementSkillState MovementSkills { get; }
|
|
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.
|
|
/// </summary>
|
|
public void InstallSpellMetadata(SpellTable spellTable)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
Spellbook.InstallMetadata(spellTable);
|
|
}
|
|
|
|
public void ResetSpellbook()
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
Spellbook.Clear();
|
|
}
|
|
|
|
public void ResetLocalPlayer()
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
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
|
|
/// invalidation callbacks, so retrying is safe and convergent.
|
|
/// </summary>
|
|
public void ResetSession()
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
List<Exception>? 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(
|
|
"Runtime character state did not converge during reset.",
|
|
failures);
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
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() =>
|
|
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<Exception>? failures)
|
|
{
|
|
try
|
|
{
|
|
action();
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
(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<uint> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Canonical session-owned copy of retail's two character-option bitfields.
|
|
/// <c>PlayerModule::PlayerModule @ 0x005D51F0</c> installs the defaults.
|
|
/// Runtime reset restores the equivalent fresh-player-module state because
|
|
/// one Runtime owner survives across graphical and no-window sessions.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|