refactor(runtime): expose canonical gameplay state
Move character options and movement skills into the Runtime-owned character graph, expose borrowed inventory, character, and social views, and route retained UI state commands through generation-gated typed Runtime contracts. Preserve the existing synchronous wire path while deleting the App-owned option and skill mirrors and extending normalized parity checkpoints. Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
parent
9d0d9b07e0
commit
dcb61efb5a
34 changed files with 2076 additions and 103 deletions
|
|
@ -1,4 +1,5 @@
|
|||
using AcDream.Core.Player;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Spells;
|
||||
|
||||
namespace AcDream.Runtime.Gameplay;
|
||||
|
|
@ -11,15 +12,27 @@ namespace AcDream.Runtime.Gameplay;
|
|||
public sealed class RuntimeCharacterState : IDisposable
|
||||
{
|
||||
private bool _disposed;
|
||||
private long _characterRevision;
|
||||
private long _spellbookRevision;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
public Spellbook Spellbook { get; }
|
||||
public LocalPlayerState LocalPlayer { get; }
|
||||
public RuntimeCharacterOptionsState Options { get; }
|
||||
public RuntimeMovementSkillState MovementSkills { get; }
|
||||
public IRuntimeCharacterView View { get; }
|
||||
public bool IsDisposed => _disposed;
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -55,6 +68,8 @@ public sealed class RuntimeCharacterState : IDisposable
|
|||
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(
|
||||
|
|
@ -68,9 +83,25 @@ public sealed class RuntimeCharacterState : IDisposable
|
|||
if (_disposed)
|
||||
return;
|
||||
ResetSession();
|
||||
Spellbook.StateChanged -= OnSpellbookChanged;
|
||||
LocalPlayer.Changed -= OnVitalChanged;
|
||||
LocalPlayer.AttributeChanged -= OnAttributeChanged;
|
||||
LocalPlayer.CharacterChanged -= OnCharacterChanged;
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -82,4 +113,212 @@ public sealed class RuntimeCharacterState : IDisposable
|
|||
(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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,10 @@ public sealed class RuntimeCommunicationState : IDisposable
|
|||
Friends = new FriendsState();
|
||||
Squelch = new SquelchState();
|
||||
View = new CommunicationView(Chat);
|
||||
SocialView = new CommunicationSocialView(
|
||||
TurbineChat,
|
||||
Friends,
|
||||
Squelch);
|
||||
}
|
||||
|
||||
public ChatLog Chat { get; }
|
||||
|
|
@ -44,6 +48,7 @@ public sealed class RuntimeCommunicationState : IDisposable
|
|||
public FriendsState Friends { get; }
|
||||
public SquelchState Squelch { get; }
|
||||
public IRuntimeChatView View { get; }
|
||||
public IRuntimeSocialView SocialView { get; }
|
||||
public IRuntimeCommunicationEventSource Events => _events;
|
||||
|
||||
public bool IsDisposed => _disposed;
|
||||
|
|
@ -79,6 +84,61 @@ public sealed class RuntimeCommunicationState : IDisposable
|
|||
public long Revision => chat.Revision;
|
||||
public int Count => chat.Count;
|
||||
}
|
||||
|
||||
private sealed class CommunicationSocialView(
|
||||
TurbineChatState turbineChat,
|
||||
FriendsState friends,
|
||||
SquelchState squelch)
|
||||
: IRuntimeSocialView
|
||||
{
|
||||
public RuntimeSocialSnapshot Snapshot
|
||||
{
|
||||
get
|
||||
{
|
||||
SquelchDatabase database = squelch.Snapshot();
|
||||
return new RuntimeSocialSnapshot(
|
||||
friends.Revision,
|
||||
friends.Count,
|
||||
squelch.Revision,
|
||||
database.Accounts.Count,
|
||||
database.Characters.Count,
|
||||
database.Global.MessageTypes.Count,
|
||||
CountRooms(turbineChat));
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryGetFriend(
|
||||
uint characterId,
|
||||
out RuntimeFriendSnapshot friend)
|
||||
{
|
||||
if (!friends.TryGet(characterId, out FriendEntry? current)
|
||||
|| current is null)
|
||||
{
|
||||
friend = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
friend = new RuntimeFriendSnapshot(
|
||||
current.Id,
|
||||
current.Name,
|
||||
current.Online,
|
||||
current.AppearOffline);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class RuntimeCommunicationEventStream
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ public sealed class RuntimeInventoryState : IDisposable
|
|||
ItemMana = new ItemManaState();
|
||||
Shortcuts = new ShortcutState();
|
||||
Transactions = new InventoryTransactionState(_entityObjects.Objects);
|
||||
View = new InventoryStateView(this);
|
||||
}
|
||||
|
||||
public ClientObjectTable Objects => _entityObjects.Objects;
|
||||
|
|
@ -28,6 +29,7 @@ public sealed class RuntimeInventoryState : IDisposable
|
|||
public ItemManaState ItemMana { get; }
|
||||
public ShortcutState Shortcuts { get; }
|
||||
public InventoryTransactionState Transactions { get; }
|
||||
public IRuntimeInventoryStateView View { get; }
|
||||
public bool IsDisposed => _disposed;
|
||||
|
||||
public void ResetExternalContainer() => ExternalContainers.Reset();
|
||||
|
|
@ -66,6 +68,61 @@ public sealed class RuntimeInventoryState : IDisposable
|
|||
(failures ??= []).Add(error);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InventoryStateView(RuntimeInventoryState owner)
|
||||
: IRuntimeInventoryStateView
|
||||
{
|
||||
public RuntimeInventoryStateSnapshot Snapshot
|
||||
{
|
||||
get
|
||||
{
|
||||
RuntimePendingInventoryRequestSnapshot? pending = null;
|
||||
if (owner.Transactions.TryGetPending(
|
||||
out PendingInventoryRequest request))
|
||||
{
|
||||
pending = new RuntimePendingInventoryRequestSnapshot(
|
||||
request.Token,
|
||||
(int)request.Kind,
|
||||
request.ItemId,
|
||||
request.Dispatched);
|
||||
}
|
||||
|
||||
return new RuntimeInventoryStateSnapshot(
|
||||
owner.ExternalContainers.RequestedContainerId,
|
||||
owner.ExternalContainers.CurrentContainerId,
|
||||
owner.Transactions.BusyCount,
|
||||
owner.Transactions.CanBeginRequest,
|
||||
pending,
|
||||
owner.Shortcuts.Items.Count,
|
||||
owner.Shortcuts.Revision,
|
||||
owner.ItemMana.Count,
|
||||
owner.ItemMana.Revision);
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryGetShortcut(
|
||||
int index,
|
||||
out RuntimeShortcutSnapshot shortcut)
|
||||
{
|
||||
IReadOnlyList<ShortcutEntry> shortcuts = owner.Shortcuts.Items;
|
||||
for (int i = 0; i < shortcuts.Count; i++)
|
||||
{
|
||||
if (shortcuts[i].Index != index)
|
||||
continue;
|
||||
ShortcutEntry current = shortcuts[i];
|
||||
shortcut = new RuntimeShortcutSnapshot(
|
||||
current.Index,
|
||||
current.ObjectId,
|
||||
current.SpellId);
|
||||
return true;
|
||||
}
|
||||
shortcut = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryGetItemMana(uint objectId, out float fraction) =>
|
||||
owner.ItemMana.TryGetManaPercent(objectId, out fraction);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ShortcutState
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue