tests/Application/InteractionAuthorityServiceTests.cs

Unit tests for InteractionAuthorityService and related behavior. Sets up fake world, interactable, directory, clock and inventory services to exercise session begin/continue/timed actions, revalidation, grants revocation and error paths.

File AccessNetworking
#nullable enable

using System;
using System.Collections.Generic;
using Hexagon.V2.Application;
using Hexagon.V2.Domain;
using Hexagon.V2.Kernel;

namespace Hexagon.V2.Tests.Application;

[TestClass]
public sealed class InteractionAuthorityServiceTests
{
	[TestMethod]
	public void ContinuingSessionRejectsEveryStaleActorBinding()
	{
		var fixture = new InteractionFixture();
		var inventoryId = InventoryId.New();
		fixture.SetGrant( inventoryId );
		var session = fixture.BeginSession();

		var wrongConnection = fixture.Service.Continue(
			session.Id,
			ConnectionId.New(),
			fixture.Actor.AccountId,
			fixture.Actor.CharacterId,
			fixture.Target );
		var wrongCharacter = fixture.Service.Continue(
			session.Id,
			fixture.Actor.ConnectionId,
			fixture.Actor.AccountId,
			CharacterId.New(),
			fixture.Target );
		var wrongTarget = fixture.Service.Continue(
			session.Id,
			fixture.Actor.ConnectionId,
			fixture.Actor.AccountId,
			fixture.Actor.CharacterId,
			InteractionTarget.SceneEntity( SceneEntityId.New() ) );
		var legitimate = fixture.Service.Continue(
			session.Id,
			fixture.Actor.ConnectionId,
			fixture.Actor.AccountId,
			fixture.Actor.CharacterId,
			fixture.Target );
		var forgedAccount = fixture.Service.Continue(
			session.Id,
			fixture.Actor.ConnectionId,
			new AccountId( fixture.Actor.AccountId.Value + 1 ),
			fixture.Actor.CharacterId,
			fixture.Target );

		Assert.AreEqual( ErrorCode.Unauthorized, wrongConnection.Error!.Code );
		Assert.AreEqual( ErrorCode.Unauthorized, wrongCharacter.Error!.Code );
		Assert.AreEqual( ErrorCode.Unauthorized, wrongTarget.Error!.Code );
		Assert.IsTrue( legitimate.Succeeded, legitimate.Error?.Message );
		Assert.AreEqual( ErrorCode.Unauthorized, forgedAccount.Error!.Code );
		Assert.IsFalse( fixture.HasGrant( inventoryId ) );
	}

	[TestMethod]
	public void BeginReconstructsAndRejectsOutOfRangeOrOccludedTargets()
	{
		var fixture = new InteractionFixture();
		fixture.World.TargetPosition = new WorldPoint( 131, 0, 0 );

		var distant = fixture.Service.Begin(
			fixture.Actor.ConnectionId,
			fixture.Actor.AccountId,
			fixture.Actor.CharacterId,
			fixture.Target );
		fixture.World.TargetPosition = new WorldPoint( 130, 0, 0 );
		fixture.World.LineOfSight = false;
		var occluded = fixture.Service.Begin(
			fixture.Actor.ConnectionId,
			fixture.Actor.AccountId,
			fixture.Actor.CharacterId,
			fixture.Target );

		Assert.AreEqual( ErrorCode.PolicyDenied, distant.Error!.Code );
		Assert.AreEqual( ErrorCode.PolicyDenied, occluded.Error!.Code );
		Assert.IsEmpty( fixture.Sessions.ActiveSessions );
	}

	[TestMethod]
	public void NonFiniteGeometryCannotBeConstructedAndHugeFiniteDistanceShortCircuitsAuthorization()
	{
		Assert.ThrowsExactly<ArgumentOutOfRangeException>( () => new WorldPoint( float.NaN, 0, 0 ) );
		Assert.ThrowsExactly<ArgumentOutOfRangeException>( () => new WorldPoint( 0, float.PositiveInfinity, 0 ) );
		Assert.ThrowsExactly<ArgumentOutOfRangeException>( () => new InteractionRange( double.NaN ) );
		Assert.ThrowsExactly<ArgumentOutOfRangeException>( () => new InteractionRange( double.PositiveInfinity ) );
		Assert.ThrowsExactly<ArgumentOutOfRangeException>( () => new InteractionRange( 0 ) );

		var fixture = new InteractionFixture();
		fixture.World.TargetPosition = new WorldPoint( float.MaxValue, float.MaxValue, float.MaxValue );
		var result = fixture.Service.Begin(
			fixture.Actor.ConnectionId,
			fixture.Actor.AccountId,
			fixture.Actor.CharacterId,
			fixture.Target );

		Assert.AreEqual( ErrorCode.PolicyDenied, result.Error!.Code );
		Assert.AreEqual( 0, fixture.World.LineOfSightCount );
		Assert.AreEqual( 0, fixture.Interactable.AuthorizeCount );
	}

	[TestMethod]
	public void OneShotAuthorizationRevalidatesWithoutOpeningSessionOrGrantingInventory()
	{
		var fixture = new InteractionFixture();
		var inventoryId = InventoryId.New();
		fixture.SetGrant( inventoryId );

		var authorized = fixture.Service.AuthorizeOneShot(
			fixture.Actor.ConnectionId,
			fixture.Actor.AccountId,
			fixture.Actor.CharacterId,
			fixture.Target );

		Assert.IsTrue( authorized.Succeeded, authorized.Error?.Message );
		Assert.IsEmpty( fixture.Sessions.ActiveSessions );
		Assert.IsFalse( fixture.HasGrant( inventoryId ) );
		Assert.AreEqual( 1, fixture.World.BuildCount );
		Assert.AreEqual( 1, fixture.Interactable.AuthorizeCount );
	}

	[TestMethod]
	public void ScheduledValidationRunsAtFourHertzAndRevokesInvalidTargetGrant()
	{
		var fixture = new InteractionFixture();
		var inventoryId = InventoryId.New();
		fixture.SetGrant( inventoryId );
		fixture.BeginSession();
		var baselineBuilds = fixture.World.BuildCount;
		fixture.World.Available = false;

		var immediate = fixture.Service.RevalidateActiveSessions();
		fixture.Clock.Advance( TimeSpan.FromMilliseconds( 249 ) );
		var early = fixture.Service.RevalidateActiveSessions();
		fixture.Clock.Advance( TimeSpan.FromMilliseconds( 1 ) );
		var due = fixture.Service.RevalidateActiveSessions();

		Assert.AreEqual( 0, immediate );
		Assert.AreEqual( 0, early );
		Assert.AreEqual( baselineBuilds + 1, fixture.World.BuildCount );
		Assert.AreEqual( 1, due );
		Assert.IsEmpty( fixture.Sessions.ActiveSessions );
		Assert.IsFalse( fixture.HasGrant( inventoryId ) );
	}

	[TestMethod]
	public void SessionExpiresAtSixtyIdleSecondsAndRevokesGrant()
	{
		var fixture = new InteractionFixture();
		var inventoryId = InventoryId.New();
		fixture.SetGrant( inventoryId );
		fixture.BeginSession();
		fixture.Clock.Advance( InteractionSessionService.DefaultIdleTimeout );

		fixture.Service.RevalidateActiveSessions();

		Assert.IsEmpty( fixture.Sessions.ActiveSessions );
		Assert.IsFalse( fixture.HasGrant( inventoryId ) );
	}

	[TestMethod]
	public void TimedActionRejectsSelfAndRevalidatesAtCompletion()
	{
		var fixture = new InteractionFixture();
		var buildsBeforeSelfAttempt = fixture.World.BuildCount;

		var self = fixture.Service.BeginTimedAction(
			fixture.Actor.ConnectionId,
			fixture.Actor.AccountId,
			fixture.Actor.CharacterId,
			InteractionTarget.Character( fixture.Actor.CharacterId ),
			TimeSpan.FromSeconds( 3 ) );
		var valid = fixture.Service.BeginTimedAction(
			fixture.Actor.ConnectionId,
			fixture.Actor.AccountId,
			fixture.Actor.CharacterId,
			fixture.Target,
			TimeSpan.FromSeconds( 3 ) );
		fixture.Clock.Advance( TimeSpan.FromSeconds( 3 ) );
		fixture.World.LineOfSight = false;
		var completed = fixture.Service.CompleteTimedAction(
			valid.Value.Id,
			fixture.Actor.ConnectionId,
			fixture.Actor.AccountId,
			fixture.Actor.CharacterId );

		Assert.AreEqual( ErrorCode.PolicyDenied, self.Error!.Code );
		Assert.AreEqual( buildsBeforeSelfAttempt + 2, fixture.World.BuildCount );
		Assert.AreEqual( ErrorCode.PolicyDenied, completed.Error!.Code );
	}

	[TestMethod]
	public void TimedActionValidatesAtBothStartAndSuccessfulCompletion()
	{
		var fixture = new InteractionFixture();

		var ticket = fixture.Service.BeginTimedAction(
			fixture.Actor.ConnectionId,
			fixture.Actor.AccountId,
			fixture.Actor.CharacterId,
			fixture.Target,
			TimeSpan.FromSeconds( 3 ) );
		fixture.Clock.Advance( TimeSpan.FromSeconds( 3 ) );
		var completed = fixture.Service.CompleteTimedAction(
			ticket.Value.Id,
			fixture.Actor.ConnectionId,
			fixture.Actor.AccountId,
			fixture.Actor.CharacterId );

		Assert.IsTrue( ticket.Succeeded, ticket.Error?.Message );
		Assert.IsTrue( completed.Succeeded, completed.Error?.Message );
		Assert.AreEqual( 2, fixture.Interactable.AuthorizeCount );
		Assert.AreEqual( 2, fixture.World.BuildCount );
	}

	[TestMethod]
	public void TimedActionCancellationRequiresTheBoundActorAndMakesTicketStale()
	{
		var fixture = new InteractionFixture();
		var ticket = fixture.Service.BeginTimedAction(
			fixture.Actor.ConnectionId,
			fixture.Actor.AccountId,
			fixture.Actor.CharacterId,
			fixture.Target,
			TimeSpan.FromSeconds( 3 ) ).Value;

		Assert.IsFalse( fixture.Service.CancelTimedAction(
			ticket.Id, ConnectionId.New(), fixture.Actor.CharacterId ) );
		Assert.IsTrue( fixture.Service.CancelTimedAction(
			ticket.Id, fixture.Actor.ConnectionId, fixture.Actor.CharacterId ) );
		fixture.Clock.Advance( TimeSpan.FromSeconds( 3 ) );
		var completed = fixture.Service.CompleteTimedAction(
			ticket.Id,
			fixture.Actor.ConnectionId,
			fixture.Actor.AccountId,
			fixture.Actor.CharacterId );

		Assert.AreEqual( ErrorCode.Unauthorized, completed.Error!.Code );
	}

	[TestMethod]
	public void TimedActionsEnforceActorCapMaximumDurationAndCompletionGrace()
	{
		var fixture = new InteractionFixture();
		var tooLong = fixture.Service.BeginTimedAction(
			fixture.Actor.ConnectionId,
			fixture.Actor.AccountId,
			fixture.Actor.CharacterId,
			fixture.Target,
			InteractionAuthorityService.MaximumTimedActionDuration + TimeSpan.FromTicks( 1 ) );
		var first = fixture.Service.BeginTimedAction(
			fixture.Actor.ConnectionId,
			fixture.Actor.AccountId,
			fixture.Actor.CharacterId,
			fixture.Target,
			TimeSpan.FromSeconds( 1 ) );
		var second = fixture.Service.BeginTimedAction(
			fixture.Actor.ConnectionId,
			fixture.Actor.AccountId,
			fixture.Actor.CharacterId,
			fixture.Target,
			TimeSpan.FromSeconds( 1 ) );

		Assert.AreEqual( ErrorCode.InvalidArgument, tooLong.Error!.Code );
		Assert.IsTrue( first.Succeeded );
		Assert.AreEqual( ErrorCode.Conflict, second.Error!.Code );
		Assert.AreEqual( 1, fixture.Service.TrackedTimedActionCount );

		fixture.Clock.Advance(
			TimeSpan.FromSeconds( 1 ) + InteractionAuthorityService.TimedActionCompletionGrace + TimeSpan.FromTicks( 1 ) );
		Assert.AreEqual( 1, fixture.Service.SweepTimedActions() );
		Assert.AreEqual( 0, fixture.Service.TrackedTimedActionCount );
	}

	[TestMethod]
	public async Task ConcurrentTimedBeginsPermitExactlyOneTicketPerActor()
	{
		var fixture = new InteractionFixture();
		var gate = new ManualResetEventSlim();
		var first = Task.Run( () =>
		{
			gate.Wait();
			return fixture.Service.BeginTimedAction(
				fixture.Actor.ConnectionId, fixture.Actor.AccountId, fixture.Actor.CharacterId,
				fixture.Target, TimeSpan.FromSeconds( 1 ) );
		} );
		var second = Task.Run( () =>
		{
			gate.Wait();
			return fixture.Service.BeginTimedAction(
				fixture.Actor.ConnectionId, fixture.Actor.AccountId, fixture.Actor.CharacterId,
				fixture.Target, TimeSpan.FromSeconds( 1 ) );
		} );
		gate.Set();
		var results = await Task.WhenAll( first, second );

		Assert.AreEqual( 1, results.Count( result => result.Succeeded ) );
		Assert.AreEqual( 1, results.Count( result => result.Error?.Code == ErrorCode.Conflict ) );
		Assert.AreEqual( 1, fixture.Service.TrackedTimedActionCount );
	}

	[TestMethod]
	public void TimedActionExpiryQueueRemainsBoundedUnderCancellationChurn()
	{
		var fixture = new InteractionFixture();
		for ( var index = 0; index < 200; index++ )
		{
			var ticket = fixture.Service.BeginTimedAction(
				fixture.Actor.ConnectionId, fixture.Actor.AccountId, fixture.Actor.CharacterId,
				fixture.Target, TimeSpan.FromMinutes( 5 ) ).Value;
			Assert.IsTrue( fixture.Service.CancelTimedAction(
				ticket.Id, fixture.Actor.ConnectionId, fixture.Actor.CharacterId ) );
		}

		Assert.AreEqual( 0, fixture.Service.TrackedTimedActionCount );
		Assert.IsLessThanOrEqualTo( 64, fixture.Service.QueuedTimedActionExpiryCount );
	}

	[TestMethod]
	public void InvalidatingTimedTargetMakesTicketStale()
	{
		var fixture = new InteractionFixture();
		var ticket = fixture.Service.BeginTimedAction(
			fixture.Actor.ConnectionId,
			fixture.Actor.AccountId,
			fixture.Actor.CharacterId,
			fixture.Target,
			TimeSpan.FromSeconds( 1 ) );
		fixture.Service.TargetInvalidated( fixture.Target, "destroyed" );
		fixture.Clock.Advance( TimeSpan.FromSeconds( 1 ) );

		var completed = fixture.Service.CompleteTimedAction(
			ticket.Value.Id,
			fixture.Actor.ConnectionId,
			fixture.Actor.AccountId,
			fixture.Actor.CharacterId );

		Assert.AreEqual( ErrorCode.Unauthorized, completed.Error!.Code );
	}

	[TestMethod]
	public void CloseCharacterChangeDisconnectAndTargetInvalidationRevokeSessionGrants()
	{
		var fixture = new InteractionFixture();

		var closedInventory = InventoryId.New();
		fixture.SetGrant( closedInventory );
		var closed = fixture.BeginSession();
		Assert.IsTrue( fixture.HasGrant( closedInventory ) );
		fixture.Service.Close( closed.Id );
		Assert.IsFalse( fixture.HasGrant( closedInventory ) );

		var changedInventory = InventoryId.New();
		fixture.SetGrant( changedInventory );
		fixture.BeginSession();
		Assert.IsTrue( fixture.HasGrant( changedInventory ) );
		fixture.Service.CharacterChanged( fixture.Actor.ConnectionId, fixture.Actor.CharacterId );
		Assert.IsFalse( fixture.HasGrant( changedInventory ) );

		var disconnectedInventory = InventoryId.New();
		fixture.SetGrant( disconnectedInventory );
		fixture.BeginSession();
		Assert.IsTrue( fixture.HasGrant( disconnectedInventory ) );
		fixture.Service.Disconnected( fixture.Actor.ConnectionId );
		Assert.IsFalse( fixture.HasGrant( disconnectedInventory ) );

		fixture.Reconnect();
		var invalidTargetInventory = InventoryId.New();
		fixture.SetGrant( invalidTargetInventory );
		fixture.BeginSession();
		Assert.IsTrue( fixture.HasGrant( invalidTargetInventory ) );
		fixture.Service.TargetInvalidated( fixture.Target, "destroyed" );
		Assert.IsFalse( fixture.HasGrant( invalidTargetInventory ) );
		Assert.IsEmpty( fixture.Sessions.ActiveSessions );
	}

	[TestMethod]
	public void ThrowingInteractableIsRevokedByScheduledValidationWithoutStarvingTheLoop()
	{
		var fixture = new InteractionFixture();
		var inventoryId = InventoryId.New();
		fixture.SetGrant( inventoryId );
		fixture.BeginSession();
		fixture.Clock.Advance( InteractionAuthorityService.RevalidationInterval );
		fixture.Interactable.AuthorizeThrows = true;

		var revoked = fixture.Service.RevalidateActiveSessions();

		Assert.AreEqual( 1, revoked, "A throwing interactable must revoke its own session, not abort the pass." );
		Assert.IsEmpty( fixture.Sessions.ActiveSessions );
		Assert.IsFalse( fixture.HasGrant( inventoryId ) );

		// The next pass runs cleanly: the offender is gone and nothing escaped the loop.
		Assert.AreEqual( 0, fixture.Service.RevalidateActiveSessions() );
	}

	[TestMethod]
	public void ThrowingInteractableFailsClosedOnEveryAuthorizationPath()
	{
		var fixture = new InteractionFixture();
		fixture.Interactable.AuthorizeThrows = true;

		var begin = fixture.Service.Begin(
			fixture.Actor.ConnectionId, fixture.Actor.AccountId, fixture.Actor.CharacterId, fixture.Target );
		var oneShot = fixture.Service.AuthorizeOneShot(
			fixture.Actor.ConnectionId, fixture.Actor.AccountId, fixture.Actor.CharacterId, fixture.Target );
		var timedBegin = fixture.Service.BeginTimedAction(
			fixture.Actor.ConnectionId, fixture.Actor.AccountId, fixture.Actor.CharacterId,
			fixture.Target, TimeSpan.FromSeconds( 1 ) );

		Assert.AreEqual( ErrorCode.InternalError, begin.Error!.Code );
		Assert.AreEqual( ErrorCode.InternalError, oneShot.Error!.Code );
		Assert.AreEqual( ErrorCode.InternalError, timedBegin.Error!.Code );
		Assert.IsEmpty( fixture.Sessions.ActiveSessions );

		fixture.Interactable.AuthorizeThrows = false;
		var session = fixture.BeginSession();
		var ticket = fixture.Service.BeginTimedAction(
			fixture.Actor.ConnectionId, fixture.Actor.AccountId, fixture.Actor.CharacterId,
			fixture.Target, TimeSpan.FromSeconds( 1 ) );
		Assert.IsTrue( ticket.Succeeded, ticket.Error?.Message );
		fixture.Clock.Advance( TimeSpan.FromSeconds( 1 ) );
		fixture.Interactable.AuthorizeThrows = true;

		var continued = fixture.Service.Continue(
			session.Id, fixture.Actor.ConnectionId, fixture.Actor.AccountId,
			fixture.Actor.CharacterId, fixture.Target );
		var completed = fixture.Service.CompleteTimedAction(
			ticket.Value.Id, fixture.Actor.ConnectionId, fixture.Actor.AccountId, fixture.Actor.CharacterId );

		Assert.AreEqual( ErrorCode.InternalError, continued.Error!.Code );
		Assert.IsEmpty( fixture.Sessions.ActiveSessions, "A throwing interactable must revoke the continued session." );
		Assert.AreEqual( ErrorCode.InternalError, completed.Error!.Code );
	}

	private sealed class InteractionFixture
	{
		public InteractionFixture()
		{
			Actor = ApplicationServiceTestEnvironment.Actor();
			Target = InteractionTarget.SceneEntity( SceneEntityId.New() );
			Clock = new MutableClock();
			World = new FakeWorld( Actor, Target );
			Interactable = new FakeInteractable( Target );
			Directory = new FakeDirectory( Interactable );
			Sessions = new InteractionSessionService( Clock );
			Access = new InventoryAccessService();
			Access.OpenConnection( Actor.ConnectionId );
			Service = new InteractionAuthorityService(
				World,
				Directory,
				Sessions,
				Access,
				ApplicationServiceTestEnvironment.AllowPolicy<ServerInteractionContext>(),
				Clock );
		}

		public InventoryActor Actor { get; }
		public InteractionTarget Target { get; }
		public MutableClock Clock { get; }
		public FakeWorld World { get; }
		public FakeInteractable Interactable { get; }
		public FakeDirectory Directory { get; }
		public InteractionSessionService Sessions { get; }
		public InventoryAccessService Access { get; }
		public InteractionAuthorityService Service { get; }

		public void Reconnect() => Access.OpenConnection( Actor.ConnectionId );

		public void SetGrant( InventoryId inventoryId )
		{
			Interactable.Offer = new InteractionOffer
			{
				SessionKind = InteractionSessionKind.Storage,
				InventoryGrants = new[]
				{
					new InteractionInventoryGrant(
						inventoryId,
						InventoryCapability.View | InventoryCapability.TransferOut,
						InventoryGrantKind.InteractionSession )
				}
			};
		}

		public InteractionSession BeginSession()
		{
			var opened = Service.Begin(
				Actor.ConnectionId,
				Actor.AccountId,
				Actor.CharacterId,
				Target );
			Assert.IsTrue( opened.Succeeded, opened.Error?.Message );
			Assert.IsNotNull( opened.Value.Session );
			return opened.Value.Session;
		}

		public bool HasGrant( InventoryId inventoryId ) => Access.Has(
			Actor.ConnectionId,
			Actor.CharacterId,
			inventoryId,
			InventoryCapability.View );
	}

	private sealed class FakeWorld : IServerInteractionWorld
	{
		private readonly InventoryActor _actor;
		private readonly InteractionTarget _target;

		public FakeWorld( InventoryActor actor, InteractionTarget target )
		{
			_actor = actor;
			_target = target;
		}

		public bool Available { get; set; } = true;
		public bool LineOfSight { get; set; } = true;
		public bool IsAlive { get; set; } = true;
		public bool IsRestrained { get; set; }
		public WorldPoint ActorPosition { get; set; } = new( 0, 0, 0 );
		public WorldPoint TargetPosition { get; set; } = new( 10, 0, 0 );
		public int BuildCount { get; private set; }
		public int LineOfSightCount { get; private set; }

		public bool TryBuildContext(
			ConnectionId connectionId,
			CharacterId characterId,
			InteractionTarget target,
			out ServerInteractionContext context )
		{
			BuildCount++;
			if ( !Available || connectionId != _actor.ConnectionId ||
				characterId != _actor.CharacterId || target != _target )
			{
				context = default!;
				return false;
			}

			context = new ServerInteractionContext
			{
				ConnectionId = _actor.ConnectionId,
				AccountId = _actor.AccountId,
				CharacterId = _actor.CharacterId,
				Target = _target,
				ActorPosition = ActorPosition,
				TargetPosition = TargetPosition,
				IsAlive = IsAlive,
				IsRestrained = IsRestrained
			};
			return true;
		}

		public bool HasLineOfSight( ServerInteractionContext context )
		{
			LineOfSightCount++;
			return LineOfSight;
		}
	}

	private sealed class FakeDirectory : IInteractionDirectory
	{
		private readonly IReadOnlyDictionary<InteractionTarget, IHexInteractable> _interactables;

		public FakeDirectory( IHexInteractable interactable ) =>
			_interactables = new Dictionary<InteractionTarget, IHexInteractable>
			{
				[interactable.Target] = interactable
			};

		public bool TryResolve( InteractionTarget target, out IHexInteractable interactable ) =>
			_interactables.TryGetValue( target, out interactable! );
	}

	private sealed class FakeInteractable : IHexInteractable
	{
		public FakeInteractable( InteractionTarget target ) => Target = target;

		public InteractionTarget Target { get; }
		public InteractionPolicy Policy { get; } = new()
		{
			SessionKind = InteractionSessionKind.Storage
		};
		public InteractionOffer Offer { get; set; } = new()
		{
			SessionKind = InteractionSessionKind.Storage
		};
		public int AuthorizeCount { get; private set; }
		public bool AuthorizeThrows { get; set; }

		public OperationResult<InteractionOffer> Authorize( ServerInteractionContext context )
		{
			AuthorizeCount++;
			if ( AuthorizeThrows ) throw new InvalidOperationException( "Interactable authorization threw." );
			return OperationResult<InteractionOffer>.Success( Offer );
		}
	}

	private sealed class MutableClock : IHexClock
	{
		public DateTimeOffset UtcNow { get; private set; } = DateTimeOffset.UnixEpoch;
		public void Advance( TimeSpan elapsed ) => UtcNow += elapsed;
	}
}