Code/V2/Application/InventoryAccess.cs

Service that manages transient, connection-scoped inventory grants and produces proofs used as commit preconditions. Stores grants per connection/character/inventory, issues per-connection epochs and incremental capability revisions, allows granting, revoking by session/character/connection, querying viewers, and validating proofs before commits.

File Access
#nullable enable

using System;
using System.Collections.Generic;
using System.Linq;
using Hexagon.V2.Domain;
using Hexagon.V2.Persistence;

namespace Hexagon.V2.Application;

[Flags]
public enum InventoryCapability
{
	None = 0,
	View = 1 << 0,
	Move = 1 << 1,
	TransferIn = 1 << 2,
	TransferOut = 1 << 3,
	Use = 1 << 4,
	Drop = 1 << 5,
	Sell = 1 << 6
}

public enum InventoryGrantKind
{
	Character,
	InteractionSession,
	NestedBag,
	Administrative
}

public sealed record InventoryGrant
{
	public required ConnectionId ConnectionId { get; init; }
	public required CharacterId CharacterId { get; init; }
	public required InventoryId InventoryId { get; init; }
	public required InventoryCapability Capabilities { get; init; }
	public required InventoryGrantKind Kind { get; init; }
	public InteractionSessionId? SessionId { get; init; }
}

public sealed class InventoryAccessProof : ICommitPrecondition
{
	private readonly InventoryAccessService _owner;

	internal InventoryAccessProof(
		InventoryAccessService owner,
		ConnectionId connectionId,
		CharacterId characterId,
		InventoryId inventoryId,
		InventoryCapability required,
		Guid connectionEpoch,
		long revision )
	{
		_owner = owner;
		ConnectionId = connectionId;
		CharacterId = characterId;
		InventoryId = inventoryId;
		Required = required;
		ConnectionEpoch = connectionEpoch;
		CapabilityRevision = revision;
	}

	public ConnectionId ConnectionId { get; }
	public CharacterId CharacterId { get; }
	public InventoryId InventoryId { get; }
	public InventoryCapability Required { get; }
	public Guid ConnectionEpoch { get; }
	public long CapabilityRevision { get; }

	public PersistenceInvariantIssue? Validate( CommitPreconditionContext context ) =>
		_owner.ValidateProof( this );
}

/// <summary>
/// Transient, connection-scoped inventory authorization. Grants are intentionally
/// absent from persisted inventory records.
/// </summary>
public sealed class InventoryAccessService
{
	private readonly object _sync = new();
	private readonly Dictionary<
		(ConnectionId Connection, InventoryId Inventory, InventoryGrantKind Kind, InteractionSessionId? Session),
		InventoryGrant> _grants = new();
	private readonly Dictionary<(ConnectionId Connection, CharacterId Character, InventoryId Inventory), long> _revisions = new();
	private readonly Dictionary<ConnectionId, Guid> _connectionEpochs = new();
	private long _lastCapabilityRevision;

	internal int RevisionEntryCount
	{
		get { lock ( _sync ) return _revisions.Count; }
	}

	internal int ConnectionEpochCount
	{
		get { lock ( _sync ) return _connectionEpochs.Count; }
	}

	/// <summary>
	/// Opens a fresh authorization epoch for one live connection. Reopening the
	/// same engine ID first removes every prior grant and tuple revision so stale
	/// work cannot survive a reconnect ABA.
	/// </summary>
	public void OpenConnection( ConnectionId connectionId )
	{
		lock ( _sync )
		{
			_ = RemoveGrantsUnderLock( grant => grant.ConnectionId == connectionId );
			foreach ( var key in _revisions.Keys.Where( key => key.Connection == connectionId ).ToArray() )
				_revisions.Remove( key );
			_connectionEpochs[connectionId] = Guid.NewGuid();
		}
	}

	public void Grant( InventoryGrant grant )
	{
		if ( grant.Capabilities == InventoryCapability.None )
			throw new ArgumentException( "An inventory grant must provide at least one capability.", nameof(grant) );

		if ( (grant.Kind is InventoryGrantKind.InteractionSession or InventoryGrantKind.NestedBag) && grant.SessionId is null )
			throw new ArgumentException( "Interaction grants require a session ID.", nameof(grant) );
		if ( (grant.Kind is InventoryGrantKind.Character or InventoryGrantKind.Administrative) && grant.SessionId is not null )
			throw new ArgumentException( "Non-session grants cannot carry a session ID.", nameof(grant) );

		lock ( _sync )
		{
			if ( !_connectionEpochs.ContainsKey( grant.ConnectionId ) )
				throw new InvalidOperationException(
					"Inventory capabilities can only be granted to an open connection epoch." );
			var grantKey = (grant.ConnectionId, grant.InventoryId, grant.Kind, grant.SessionId);
			_grants.TryGetValue( grantKey, out var previous );
			_grants[grantKey] = grant;
			if ( previous is not null && previous.CharacterId != grant.CharacterId )
				PruneOrAdvance( previous.ConnectionId, previous.CharacterId, previous.InventoryId );
			Advance( grant.ConnectionId, grant.CharacterId, grant.InventoryId );
		}
	}

	public InventoryAccessProof? Prove(
		ConnectionId connectionId,
		CharacterId characterId,
		InventoryId inventoryId,
		InventoryCapability required )
	{
		lock ( _sync )
		{
			if ( !HasUnderLock( connectionId, characterId, inventoryId, required ) ) return null;
			if ( !_connectionEpochs.TryGetValue( connectionId, out var connectionEpoch ) ) return null;
			if ( !_revisions.TryGetValue( (connectionId, characterId, inventoryId), out var revision ) ) return null;
			return new InventoryAccessProof(
				this, connectionId, characterId, inventoryId, required,
				connectionEpoch, revision );
		}
	}

	public bool Has( ConnectionId connectionId, CharacterId characterId, InventoryId inventoryId, InventoryCapability required )
	{
		lock ( _sync )
		{
			return HasUnderLock( connectionId, characterId, inventoryId, required );
		}
	}

	public IReadOnlyList<ConnectionId> GetViewers( InventoryId inventoryId )
	{
		lock ( _sync )
		{
			return _grants.Values
				.Where( grant => grant.InventoryId == inventoryId && (grant.Capabilities & InventoryCapability.View) != 0 )
				.Select( grant => grant.ConnectionId )
				.Distinct()
				.ToArray();
		}
	}

	public IReadOnlyList<InventoryGrant> RevokeSession( InteractionSessionId sessionId ) =>
		RevokeWhere( grant => grant.SessionId == sessionId );

	public IReadOnlyList<InventoryGrant> RevokeCharacter( ConnectionId connectionId, CharacterId characterId ) =>
		RevokeWhere( grant => grant.ConnectionId == connectionId && grant.CharacterId == characterId );

	public IReadOnlyList<InventoryGrant> RevokeConnection( ConnectionId connectionId )
	{
		lock ( _sync )
		{
			var removed = RemoveGrantsUnderLock( grant => grant.ConnectionId == connectionId );
			foreach ( var key in _revisions.Keys.Where( key => key.Connection == connectionId ).ToArray() )
				_revisions.Remove( key );
			_connectionEpochs.Remove( connectionId );
			return removed;
		}
	}

	private IReadOnlyList<InventoryGrant> RevokeWhere( Func<InventoryGrant, bool> predicate )
	{
		lock ( _sync )
		{
			var removed = RemoveGrantsUnderLock( predicate );
			foreach ( var grant in removed
				.DistinctBy( grant => (grant.ConnectionId, grant.CharacterId, grant.InventoryId) ) )
				PruneOrAdvance( grant.ConnectionId, grant.CharacterId, grant.InventoryId );
			return removed;
		}
	}

	internal PersistenceInvariantIssue? ValidateProof( InventoryAccessProof proof )
	{
		lock ( _sync )
		{
			return _connectionEpochs.TryGetValue( proof.ConnectionId, out var connectionEpoch ) &&
				connectionEpoch == proof.ConnectionEpoch &&
				_revisions.TryGetValue( (proof.ConnectionId, proof.CharacterId, proof.InventoryId), out var revision ) &&
				revision == proof.CapabilityRevision &&
				HasUnderLock( proof.ConnectionId, proof.CharacterId, proof.InventoryId, proof.Required )
				? null
				: new PersistenceInvariantIssue(
					"inventory.access.stale",
					$"inventory/{proof.InventoryId}/access",
					"Inventory capability changed after the action was planned." );
		}
	}

	private bool HasUnderLock(
		ConnectionId connectionId,
		CharacterId characterId,
		InventoryId inventoryId,
		InventoryCapability required )
	{
		if ( !_connectionEpochs.ContainsKey( connectionId ) ) return false;
		var capabilities = InventoryCapability.None;
		foreach ( var grant in _grants.Values.Where( value =>
			value.ConnectionId == connectionId && value.InventoryId == inventoryId &&
			value.CharacterId == characterId ) )
			capabilities |= grant.Capabilities;
		return (capabilities & required) == required;
	}

	private void Advance( ConnectionId connectionId, CharacterId characterId, InventoryId inventoryId )
	{
		if ( !_connectionEpochs.ContainsKey( connectionId ) )
			throw new InvalidOperationException(
				"Inventory capability revision cannot advance for a closed connection." );
		_revisions[(connectionId, characterId, inventoryId)] = checked(++_lastCapabilityRevision);
	}

	private void PruneOrAdvance( ConnectionId connectionId, CharacterId characterId, InventoryId inventoryId )
	{
		var key = (connectionId, characterId, inventoryId);
		if ( _grants.Values.Any( grant =>
			grant.ConnectionId == connectionId && grant.CharacterId == characterId &&
			grant.InventoryId == inventoryId ) )
		{
			Advance( connectionId, characterId, inventoryId );
			return;
		}
		_revisions.Remove( key );
	}

	private InventoryGrant[] RemoveGrantsUnderLock( Func<InventoryGrant, bool> predicate )
	{
		var removed = _grants.Values.Where( predicate ).ToArray();
		foreach ( var grant in removed )
			_grants.Remove( (grant.ConnectionId, grant.InventoryId, grant.Kind, grant.SessionId) );
		return removed;
	}

}