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:
Erik 2026-07-26 08:39:02 +02:00
parent d362172620
commit d02a12ceac
16 changed files with 418 additions and 96 deletions

View 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);
}
}
}

View file

@ -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)>());
}