refactor(runtime): own magic and player state
Move the coupled Spellbook and LocalPlayerState into one Runtime-owned character graph, route content, live-session, retained UI, reset, and shutdown through that exact owner, and delete the duplicate desired-component snapshot from inventory state. Preserve synchronous retail update and reset ordering while adding independent-instance and retryable-failure coverage. Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
parent
d362172620
commit
d02a12ceac
16 changed files with 418 additions and 96 deletions
85
src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs
Normal file
85
src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
using AcDream.Core.Player;
|
||||
using AcDream.Core.Spells;
|
||||
|
||||
namespace AcDream.Runtime.Gameplay;
|
||||
|
||||
/// <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;
|
||||
|
||||
public RuntimeCharacterState(SpellTable? spellTable = null)
|
||||
{
|
||||
Spellbook = new Spellbook(spellTable);
|
||||
LocalPlayer = new LocalPlayerState(Spellbook);
|
||||
}
|
||||
|
||||
public Spellbook Spellbook { get; }
|
||||
public LocalPlayerState LocalPlayer { get; }
|
||||
public bool IsDisposed => _disposed;
|
||||
|
||||
/// <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>
|
||||
/// 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);
|
||||
if (failures is not null)
|
||||
{
|
||||
throw new AggregateException(
|
||||
"Runtime character state did not converge during reset.",
|
||||
failures);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
ResetSession();
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private static void Try(Action action, ref List<Exception>? failures)
|
||||
{
|
||||
try
|
||||
{
|
||||
action();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
(failures ??= []).Add(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -20,7 +20,6 @@ public sealed class RuntimeInventoryState : IDisposable
|
|||
ExternalContainers = new ExternalContainerState();
|
||||
ItemMana = new ItemManaState();
|
||||
Shortcuts = new ShortcutState();
|
||||
DesiredComponents = new DesiredComponentState();
|
||||
Transactions = new InventoryTransactionState(_entityObjects.Objects);
|
||||
}
|
||||
|
||||
|
|
@ -28,7 +27,6 @@ public sealed class RuntimeInventoryState : IDisposable
|
|||
public ExternalContainerState ExternalContainers { get; }
|
||||
public ItemManaState ItemMana { get; }
|
||||
public ShortcutState Shortcuts { get; }
|
||||
public DesiredComponentState DesiredComponents { get; }
|
||||
public InventoryTransactionState Transactions { get; }
|
||||
public bool IsDisposed => _disposed;
|
||||
|
||||
|
|
@ -39,7 +37,6 @@ public sealed class RuntimeInventoryState : IDisposable
|
|||
public void ResetPlayerSnapshots()
|
||||
{
|
||||
Shortcuts.Clear();
|
||||
DesiredComponents.Clear();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
|
@ -50,7 +47,6 @@ public sealed class RuntimeInventoryState : IDisposable
|
|||
Try(() => ExternalContainers.Reset(), ref failures);
|
||||
Try(ItemMana.Clear, ref failures);
|
||||
Try(Shortcuts.Clear, ref failures);
|
||||
Try(DesiredComponents.Clear, ref failures);
|
||||
Try(Transactions.Dispose, ref failures);
|
||||
if (failures is not null)
|
||||
throw new AggregateException(
|
||||
|
|
@ -90,25 +86,3 @@ public sealed class ShortcutState
|
|||
|
||||
public void Clear() => Replace(Array.Empty<ShortcutEntry>());
|
||||
}
|
||||
|
||||
public sealed class DesiredComponentState
|
||||
{
|
||||
private IReadOnlyList<(uint Id, uint Amount)> _items =
|
||||
Array.Empty<(uint Id, uint Amount)>();
|
||||
private long _revision;
|
||||
|
||||
public IReadOnlyList<(uint Id, uint Amount)> Items =>
|
||||
Volatile.Read(ref _items);
|
||||
public long Revision => Interlocked.Read(ref _revision);
|
||||
|
||||
public void Replace(IReadOnlyList<(uint Id, uint Amount)>? items)
|
||||
{
|
||||
Volatile.Write(
|
||||
ref _items,
|
||||
items ?? Array.Empty<(uint Id, uint Amount)>());
|
||||
Interlocked.Increment(ref _revision);
|
||||
}
|
||||
|
||||
public void Clear() =>
|
||||
Replace(Array.Empty<(uint Id, uint Amount)>());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ using AcDream.Core.Net.Messages;
|
|||
using AcDream.Core.Player;
|
||||
using AcDream.Core.Social;
|
||||
using AcDream.Core.Spells;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
|
||||
namespace AcDream.Runtime.Session;
|
||||
|
||||
|
|
@ -29,18 +30,16 @@ public sealed record LiveEnvironmentSessionSink(
|
|||
|
||||
public sealed record LiveInventorySessionBindings(
|
||||
ClientObjectTable Objects,
|
||||
LocalPlayerState LocalPlayer,
|
||||
Func<uint> PlayerGuid,
|
||||
Action<IReadOnlyList<ShortcutEntry>>? OnShortcuts,
|
||||
Action<uint>? OnUseDone,
|
||||
ItemManaState? ItemMana,
|
||||
Action<IReadOnlyList<(uint Id, uint Amount)>>? OnDesiredComponents,
|
||||
ExternalContainerState? ExternalContainers,
|
||||
Action<AppraiseInfoParser.Parsed>? OnAppraisal = null);
|
||||
|
||||
public sealed record LiveCharacterSessionBindings(
|
||||
CombatState Combat,
|
||||
Spellbook Spellbook,
|
||||
RuntimeCharacterState Character,
|
||||
Func<uint, IReadOnlyDictionary<uint, uint>, uint>? ResolveSkillFormulaBonus,
|
||||
Action<int, int>? OnSkillsUpdated,
|
||||
Action<GameEvents.CharacterConfirmationRequest>? OnConfirmationRequest,
|
||||
|
|
@ -114,7 +113,7 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
|
|||
session,
|
||||
inventory.Objects,
|
||||
inventory.PlayerGuid,
|
||||
inventory.LocalPlayer,
|
||||
character.Character.LocalPlayer,
|
||||
IsAccepting));
|
||||
ConstructionCheckpoint();
|
||||
_subscriptions.Add(CombatStateWiring.Wire(
|
||||
|
|
@ -149,9 +148,9 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
|
|||
session.GameEvents,
|
||||
inventory.Objects,
|
||||
character.Combat,
|
||||
character.Spellbook,
|
||||
character.Character.Spellbook,
|
||||
social.Chat,
|
||||
inventory.LocalPlayer,
|
||||
character.Character.LocalPlayer,
|
||||
social.TurbineChat,
|
||||
onSkillsUpdated: character.OnSkillsUpdated,
|
||||
resolveSkillFormulaBonus: character.ResolveSkillFormulaBonus,
|
||||
|
|
@ -164,7 +163,7 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
|
|||
onConfirmationDone: character.OnConfirmationDone,
|
||||
friends: social.Friends,
|
||||
squelch: social.Squelch,
|
||||
onDesiredComponents: inventory.OnDesiredComponents,
|
||||
onDesiredComponents: null,
|
||||
onCharacterOptions: character.OnCharacterOptions,
|
||||
clientTime: character.ClientTime,
|
||||
externalContainers: inventory.ExternalContainers,
|
||||
|
|
@ -202,7 +201,7 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
|
|||
h => session.TurbineChatReceived -= h,
|
||||
parsed => RouteTurbineChat(social.Chat, parsed));
|
||||
Subscribe<PrivateUpdateVital.ParsedFull>(h => session.VitalUpdated += h, h => session.VitalUpdated -= h, vital =>
|
||||
inventory.LocalPlayer.OnVitalUpdate(
|
||||
character.Character.LocalPlayer.OnVitalUpdate(
|
||||
vital.VitalId,
|
||||
vital.Ranks,
|
||||
vital.Start,
|
||||
|
|
@ -211,7 +210,9 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
|
|||
Subscribe<PrivateUpdateVital.ParsedCurrent>(
|
||||
h => session.VitalCurrentUpdated += h,
|
||||
h => session.VitalCurrentUpdated -= h,
|
||||
vital => inventory.LocalPlayer.OnVitalCurrent(vital.VitalId, vital.Current));
|
||||
vital => character.Character.LocalPlayer.OnVitalCurrent(
|
||||
vital.VitalId,
|
||||
vital.Current));
|
||||
|
||||
if (Interlocked.CompareExchange(ref _lifecycleState, 2, 1) != 1)
|
||||
throw new ObjectDisposedException(nameof(LiveSessionEventRouter));
|
||||
|
|
@ -297,10 +298,9 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
|
|||
ArgumentNullException.ThrowIfNull(environment.EnvironChanged);
|
||||
ArgumentNullException.ThrowIfNull(environment.ServerTimeUpdated);
|
||||
ArgumentNullException.ThrowIfNull(inventory.Objects);
|
||||
ArgumentNullException.ThrowIfNull(inventory.LocalPlayer);
|
||||
ArgumentNullException.ThrowIfNull(inventory.PlayerGuid);
|
||||
ArgumentNullException.ThrowIfNull(character.Combat);
|
||||
ArgumentNullException.ThrowIfNull(character.Spellbook);
|
||||
ArgumentNullException.ThrowIfNull(character.Character);
|
||||
ArgumentNullException.ThrowIfNull(social.Chat);
|
||||
ArgumentNullException.ThrowIfNull(social.TurbineChat);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue