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:
parent
bb45afef33
commit
b298f99f91
38 changed files with 711 additions and 93 deletions
23
src/AcDream.Runtime/GameRuntimeActionViews.cs
Normal file
23
src/AcDream.Runtime/GameRuntimeActionViews.cs
Normal 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);
|
||||
}
|
||||
|
|
@ -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}")));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
96
src/AcDream.Runtime/Gameplay/InteractionState.cs
Normal file
96
src/AcDream.Runtime/Gameplay/InteractionState.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
177
src/AcDream.Runtime/Gameplay/RuntimeActionState.cs
Normal file
177
src/AcDream.Runtime/Gameplay/RuntimeActionState.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue