refactor(runtime): own canonical action state

Move selection, combat, and interaction target mode under one Runtime owner; make plugins, retained UI, session routing, and typed runtime views borrow its exact children; and add failure-safe reset, instance isolation, source ownership, and normalized checkpoint coverage without changing retail ordering.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-26 10:44:09 +02:00
parent bb45afef33
commit b298f99f91
38 changed files with 711 additions and 93 deletions

View file

@ -0,0 +1,177 @@
using AcDream.Core.Combat;
using AcDream.Core.Selection;
namespace AcDream.Runtime.Gameplay;
public readonly record struct RuntimeActionOwnershipSnapshot(
bool IsDisposed,
bool InternalSubscriptionsAttached,
uint SelectedObjectId,
uint PreviousObjectId,
uint PreviousValidObjectId,
CombatMode CombatMode,
int TrackedTargetHealthCount,
InteractionMode InteractionMode,
long SelectionRevision,
long CombatRevision,
long InteractionRevision)
{
public bool IsConverged =>
IsDisposed
&& !InternalSubscriptionsAttached
&& SelectedObjectId == 0u
&& PreviousObjectId == 0u
&& PreviousValidObjectId == 0u
&& CombatMode == CombatMode.NonCombat
&& TrackedTargetHealthCount == 0
&& InteractionMode == InteractionMode.None;
}
/// <summary>
/// Canonical presentation-independent owner for selection, combat
/// notifications/mode, and temporary interaction target mode. Graphical and
/// no-window hosts borrow these exact instances.
/// </summary>
public sealed class RuntimeActionState : IDisposable
{
private bool _disposed;
private bool _internalSubscriptionsAttached;
private long _selectionRevision;
private long _combatRevision;
private long _interactionRevision;
public RuntimeActionState()
{
Selection = new SelectionState();
Combat = new CombatState();
Interaction = new InteractionState();
View = new ActionView(this);
Selection.Changed += OnSelectionChanged;
Combat.CombatModeChanged += OnCombatModeChanged;
Combat.HealthChanged += OnHealthChanged;
Interaction.Changed += OnInteractionChanged;
_internalSubscriptionsAttached = true;
}
public SelectionState Selection { get; }
public CombatState Combat { get; }
public InteractionState Interaction { get; }
public IRuntimeActionView View { get; }
public bool IsDisposed => _disposed;
public RuntimeActionOwnershipSnapshot CaptureOwnership() => new(
_disposed,
_internalSubscriptionsAttached,
Selection.SelectedObjectId ?? 0u,
Selection.PreviousObjectId ?? 0u,
Selection.PreviousValidObjectId ?? 0u,
Combat.CurrentMode,
Combat.TrackedTargetCount,
Interaction.Current,
Interlocked.Read(ref _selectionRevision),
Interlocked.Read(ref _combatRevision),
Interlocked.Read(ref _interactionRevision));
/// <summary>
/// Restores the action group to the equivalent of a fresh retail character
/// session. Each owner commits before notifying observers, so every suffix
/// is attempted and a retry converges after observer failure.
/// </summary>
public void ResetSession()
{
ObjectDisposedException.ThrowIf(_disposed, this);
List<Exception>? failures = null;
Try(Interaction.ResetSession, ref failures);
Try(() => Selection.Reset(), ref failures);
Try(Combat.Clear, ref failures);
if (failures is not null)
{
throw new AggregateException(
"Runtime action state did not converge during reset.",
failures);
}
}
public void Dispose()
{
if (_disposed)
return;
List<Exception>? failures = null;
try
{
Try(Interaction.ResetSession, ref failures);
Try(() => Selection.Reset(), ref failures);
Try(Combat.Clear, ref failures);
}
finally
{
Selection.Changed -= OnSelectionChanged;
Combat.CombatModeChanged -= OnCombatModeChanged;
Combat.HealthChanged -= OnHealthChanged;
Interaction.Changed -= OnInteractionChanged;
_internalSubscriptionsAttached = false;
_disposed = true;
}
if (failures is not null)
{
throw new AggregateException(
"Runtime action state did not converge during disposal.",
failures);
}
}
private void OnSelectionChanged(SelectionTransition _) =>
Interlocked.Increment(ref _selectionRevision);
private void OnCombatModeChanged(CombatMode _) =>
Interlocked.Increment(ref _combatRevision);
private void OnHealthChanged(uint _, float __) =>
Interlocked.Increment(ref _combatRevision);
private void OnInteractionChanged(InteractionModeTransition _) =>
Interlocked.Increment(ref _interactionRevision);
private static void Try(Action action, ref List<Exception>? failures)
{
try
{
action();
}
catch (Exception error)
{
(failures ??= []).Add(error);
}
}
private sealed class ActionView(RuntimeActionState owner)
: IRuntimeActionView
{
public RuntimeActionSnapshot Snapshot => new(
Interlocked.Read(ref owner._selectionRevision),
owner.Selection.SelectedObjectId ?? 0u,
owner.Selection.PreviousObjectId ?? 0u,
owner.Selection.PreviousValidObjectId ?? 0u,
Interlocked.Read(ref owner._combatRevision),
owner.Combat.CurrentMode,
owner.Combat.TrackedTargetCount,
Interlocked.Read(ref owner._interactionRevision),
owner.Interaction.Current.Kind,
owner.Interaction.Current.SourceObjectId);
public bool TryGetHealth(uint objectId, out float healthPercent)
{
if (!owner.Combat.HasHealth(objectId))
{
healthPercent = 0f;
return false;
}
healthPercent = owner.Combat.GetHealthPercent(objectId);
return true;
}
}
}