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,23 @@
using AcDream.Core.Combat;
using AcDream.Runtime.Gameplay;
namespace AcDream.Runtime;
public readonly record struct RuntimeActionSnapshot(
long SelectionRevision,
uint SelectedObjectId,
uint PreviousObjectId,
uint PreviousValidObjectId,
long CombatRevision,
CombatMode CombatMode,
int TrackedTargetHealthCount,
long InteractionRevision,
InteractionModeKind InteractionMode,
uint InteractionSourceObjectId);
public interface IRuntimeActionView
{
RuntimeActionSnapshot Snapshot { get; }
bool TryGetHealth(uint objectId, out float healthPercent);
}

View file

@ -162,6 +162,14 @@ public sealed class RuntimeTraceRecorder : IRuntimeEventObserver
$"social={checkpoint.Social.FriendsRevision}:" +
$"{checkpoint.Social.FriendCount}:" +
$"{checkpoint.Social.SquelchRevision};" +
$"actions={checkpoint.Actions.SelectionRevision}:" +
$"{checkpoint.Actions.SelectedObjectId:X8}:" +
$"{checkpoint.Actions.CombatRevision}:" +
$"{(int)checkpoint.Actions.CombatMode}:" +
$"{checkpoint.Actions.TrackedTargetHealthCount}:" +
$"{checkpoint.Actions.InteractionRevision}:" +
$"{(int)checkpoint.Actions.InteractionMode}:" +
$"{checkpoint.Actions.InteractionSourceObjectId:X8};" +
$"portal={checkpoint.Portal.Generation}:{(int)checkpoint.Portal.Kind}:" +
$"{checkpoint.Portal.DestinationCell:X8}:{checkpoint.Portal.IsReady}")));
}

View file

@ -115,6 +115,7 @@ public readonly record struct RuntimeStateCheckpoint(
RuntimeSocialSnapshot Social,
long ChatRevision,
int ChatCount,
RuntimeActionSnapshot Actions,
RuntimeMovementSnapshot Movement,
RuntimePortalSnapshot Portal);
@ -138,6 +139,8 @@ public interface IGameRuntimeView
IRuntimeChatView Chat { get; }
IRuntimeActionView Actions { get; }
IRuntimeMovementView Movement { get; }
IRuntimePortalView Portal { get; }

View file

@ -0,0 +1,96 @@
namespace AcDream.Runtime.Gameplay;
public enum InteractionModeKind
{
None,
Use,
Examine,
UseItemOnTarget,
}
public readonly record struct InteractionMode(
InteractionModeKind Kind,
uint SourceObjectId = 0)
{
public static InteractionMode None => new(InteractionModeKind.None);
}
public readonly record struct InteractionModeTransition(
InteractionMode Previous,
InteractionMode Current);
/// <summary>
/// Canonical presentation-independent owner for retail's temporary target
/// mode. App projects this state into a cursor image; it does not own or copy
/// the mode.
/// </summary>
public sealed class InteractionState
{
public InteractionMode Current { get; private set; } = InteractionMode.None;
public event Action<InteractionModeTransition>? Changed;
public bool EnterUse() => Set(new InteractionMode(InteractionModeKind.Use));
public bool EnterExamine() =>
Set(new InteractionMode(InteractionModeKind.Examine));
public bool EnterUseItemOnTarget(uint sourceObjectId)
{
if (sourceObjectId == 0)
throw new ArgumentOutOfRangeException(nameof(sourceObjectId));
return Set(new InteractionMode(
InteractionModeKind.UseItemOnTarget,
sourceObjectId));
}
public bool Clear() => Set(InteractionMode.None);
/// <summary>
/// Publishes the session-reset edge even when the mode is already clear so
/// a retry can repair any observer that missed an earlier notification.
/// State commits before observers run and every observer is attempted.
/// </summary>
public void ResetSession()
{
InteractionMode previous = Current;
Current = InteractionMode.None;
Action<InteractionModeTransition>? listeners = Changed;
if (listeners is null)
return;
var transition = new InteractionModeTransition(
previous,
InteractionMode.None);
List<Exception>? failures = null;
foreach (Action<InteractionModeTransition> listener
in listeners.GetInvocationList())
{
try
{
listener(transition);
}
catch (Exception error)
{
(failures ??= []).Add(error);
}
}
if (failures is not null)
{
throw new AggregateException(
"One or more interaction-mode reset observers failed.",
failures);
}
}
private bool Set(InteractionMode mode)
{
if (Current == mode)
return false;
InteractionMode previous = Current;
Current = mode;
Changed?.Invoke(new InteractionModeTransition(previous, mode));
return true;
}
}

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

View file

@ -1,19 +1,21 @@
namespace AcDream.Runtime.Gameplay;
/// <summary>
/// One allocation-free ledger over the complete J4 gameplay-state lifetime
/// group. It contains no state of its own; graphical and no-window hosts
/// capture the same three canonical owners.
/// One allocation-free ledger over the gameplay-state lifetime group through
/// J5.1. It contains no state of its own; graphical and no-window hosts capture
/// the same four canonical owners.
/// </summary>
public readonly record struct RuntimeGameplayOwnershipSnapshot(
RuntimeInventoryOwnershipSnapshot Inventory,
RuntimeCharacterOwnershipSnapshot Character,
RuntimeCommunicationOwnershipSnapshot Communication)
RuntimeCommunicationOwnershipSnapshot Communication,
RuntimeActionOwnershipSnapshot Actions)
{
public bool IsConverged =>
Inventory.IsConverged
&& Character.IsConverged
&& Communication.IsConverged;
&& Communication.IsConverged
&& Actions.IsConverged;
}
public static class RuntimeGameplayOwnership
@ -21,14 +23,17 @@ public static class RuntimeGameplayOwnership
public static RuntimeGameplayOwnershipSnapshot Capture(
RuntimeInventoryState inventory,
RuntimeCharacterState character,
RuntimeCommunicationState communication)
RuntimeCommunicationState communication,
RuntimeActionState actions)
{
ArgumentNullException.ThrowIfNull(inventory);
ArgumentNullException.ThrowIfNull(character);
ArgumentNullException.ThrowIfNull(communication);
ArgumentNullException.ThrowIfNull(actions);
return new RuntimeGameplayOwnershipSnapshot(
inventory.CaptureOwnership(),
character.CaptureOwnership(),
communication.CaptureOwnership());
communication.CaptureOwnership(),
actions.CaptureOwnership());
}
}