feat(headless): complete deterministic bot command parity

This commit is contained in:
Erik 2026-07-27 08:23:36 +02:00
parent 7e8acb74dd
commit 38e83640d9
37 changed files with 2805 additions and 295 deletions

View file

@ -48,21 +48,26 @@ internal sealed class GameRuntimeEventHub
{
private readonly RuntimeEntityObjectLifetime _entityObjects;
private readonly RuntimeCommunicationState _communication;
private readonly RuntimeActionState _actions;
private readonly object _observerGate = new();
private IRuntimeEventObserver[] _observers = [];
private IDisposable? _entityObjectSubscription;
private IDisposable? _communicationSubscription;
private bool _actionEventsAttached;
private bool _disposeRequested;
private bool _disposed;
public GameRuntimeEventHub(
RuntimeEntityObjectLifetime entityObjects,
RuntimeCommunicationState communication)
RuntimeCommunicationState communication,
RuntimeActionState actions)
{
_entityObjects = entityObjects
?? throw new ArgumentNullException(nameof(entityObjects));
_communication = communication
?? throw new ArgumentNullException(nameof(communication));
_actions = actions
?? throw new ArgumentNullException(nameof(actions));
}
public long DispatchFailureCount { get; private set; }
@ -127,6 +132,7 @@ internal sealed class GameRuntimeEventHub
ref _entityObjectSubscription,
"entity/object event subscription",
ref failures);
DetachActionEvents();
_disposed = !OwnerEventsAttached;
if (failures is not null)
@ -284,7 +290,8 @@ internal sealed class GameRuntimeEventHub
private bool OwnerEventsAttached =>
_entityObjectSubscription is not null
|| _communicationSubscription is not null;
|| _communicationSubscription is not null
|| _actionEventsAttached;
private void AttachOwnerEvents()
{
@ -294,11 +301,14 @@ internal sealed class GameRuntimeEventHub
{
entity = _entityObjects.Events.Subscribe(this);
communication = _communication.Events.Subscribe(this);
_actions.CombatChanged += OnCombatChanged;
_actionEventsAttached = true;
_entityObjectSubscription = entity;
_communicationSubscription = communication;
}
catch
{
DetachActionEvents();
communication?.Dispose();
entity?.Dispose();
throw;
@ -347,6 +357,7 @@ internal sealed class GameRuntimeEventHub
ref _entityObjectSubscription,
"entity/object event subscription",
ref failures);
DetachActionEvents();
if (failures is not null)
{
throw new AggregateException(
@ -378,6 +389,37 @@ internal sealed class GameRuntimeEventHub
private RuntimeEventStamp NextStamp() =>
_entityObjects.Events.NextStamp();
private void OnCombatChanged()
{
if (!TryObservers(out IRuntimeEventObserver[] observers))
return;
RuntimeActionSnapshot actions = _actions.View.Snapshot;
var delta = new RuntimeCombatDelta(
NextStamp(),
actions.CombatMode,
actions.TrackedTargetHealthCount,
actions.CombatAttack);
foreach (IRuntimeEventObserver observer in observers)
{
try
{
observer.OnCombat(in delta);
}
catch (Exception error)
{
RecordDispatchFailure(error);
}
}
}
private void DetachActionEvents()
{
if (!_actionEventsAttached)
return;
_actions.CombatChanged -= OnCombatChanged;
_actionEventsAttached = false;
}
private bool TryObservers(out IRuntimeEventObserver[] observers)
{
observers = Volatile.Read(ref _observers);