Host-side RPC component and a sandboxed client transport adapter. HexHostServicesComponent exposes RPC endpoints to accept client commands, validate and dispatch them through the runtime, encode/decode snapshots and forward results via RPC. SandboxClientCommandTransport is a client-side transport implementation that binds a local client scope to host services, sends commands by invoking the host RPC methods, and tracks pending command completions.
#nullable enable
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Hexagon.V2.Client;
using Hexagon.V2.Domain;
using Hexagon.V2.Infrastructure;
using Hexagon.V2.Kernel;
using Hexagon.V2.Networking;
using Sandbox;
using ClientInventorySnapshot = Hexagon.V2.Networking.InventorySnapshot;
namespace Hexagon.V2.Runtime;
/// <summary>
/// Single host-owned RPC surface for framework commands. Every entry point
/// derives its actor through RpcGuard before dispatching client intent.
/// </summary>
public sealed class HexHostServicesComponent : Component, IHexHostTransport
{
internal HexagonRuntimeSystem? Runtime { get; set; }
[Rpc.Host]
public void ReportClientBootstrapDiagnostic(
ClientBootstrapDiagnosticPhase phase,
ClientBootstrapDiagnosticCode code,
string detail )
{
var runtime = Runtime;
var caller = Rpc.Caller;
if ( runtime is null || caller is null || caller.SteamId.ValueUnsigned == 0 ) return;
var admitted = runtime.TryAcceptClientBootstrapDiagnostic( caller, phase, code, detail );
if ( !admitted.Accepted ) return;
var diagnostic = admitted.Diagnostic;
var safeDetail = ClientBootstrapDiagnosticContract.EscapeLogValue( diagnostic.Detail );
Log.Error(
$"HEXAGON_CLIENT_FAILED source=remote account={caller.SteamId.ValueUnsigned} " +
$"connection={caller.Id} phase={diagnostic.Phase} code={diagnostic.Code} " +
$"detail=\"{safeDetail}\"" );
}
[Rpc.Host]
public void RequestEstablishSession( ClientSessionNonce nonce )
{
var runtime = Runtime;
var caller = Rpc.Caller;
if ( runtime is null || caller is null || caller.SteamId.ValueUnsigned == 0 ) return;
var bound = runtime.BindClientSession( caller, nonce );
if ( bound.Failed )
{
if ( bound.Error!.Code != ErrorCode.Conflict )
Log.Warning( $"Hexagon rejected client session handshake: {bound.Error.Message}" );
return;
}
using ( Rpc.FilterInclude( caller ) ) ReceiveSessionHello( new ClientSessionHello( bound.Value ) );
var completed = runtime.CompleteClientSessionHello( caller, bound.Value );
if ( completed.Failed )
{
using ( Rpc.FilterInclude( caller ) ) ReceiveSessionRevoked( bound.Value );
Log.Warning( $"Hexagon client session hello could not complete: {completed.Error!.Message}" );
}
}
[Rpc.Host]
public void RequestCharacterList( ClientCommandHeader header ) =>
Dispatch( header, ClientCommandCosts.CharacterList, static () => new RequestCharacterListCommand() );
[Rpc.Host]
public void RequestCreateCharacter(
ClientCommandHeader header,
string name,
string description,
DefinitionId model,
FactionId faction,
ClassId? characterClass,
Dictionary<string, SnapshotValue> fields ) =>
Dispatch( header, ClientCommandCosts.CreateCharacter, () => new CreateCharacterCommand(
new CharacterCreationInput( name, description, model, faction, characterClass, fields ) ) );
[Rpc.Host]
public void RequestLoadCharacter( ClientCommandHeader header, CharacterId characterId ) =>
Dispatch( header, ClientCommandCosts.LoadCharacter, () => new LoadCharacterCommand( characterId ) );
[Rpc.Host]
public void RequestDeleteCharacter( ClientCommandHeader header, CharacterId characterId ) =>
Dispatch( header, ClientCommandCosts.DeleteCharacter, () => new DeleteCharacterCommand( characterId ) );
[Rpc.Host]
public void RequestUnloadCharacter( ClientCommandHeader header ) =>
Dispatch( header, ClientCommandCosts.UnloadCharacter, static () => new UnloadCharacterCommand() );
[Rpc.Host]
public void RequestMoveItem(
ClientCommandHeader header,
InventoryId sourceId,
InventoryId targetId,
ItemId itemId,
InventoryGridPosition position ) =>
Dispatch( header, ClientCommandCosts.MoveItem, () => new MoveInventoryItemCommand( sourceId, targetId, itemId, position ) );
[Rpc.Host]
public void RequestItemAction(
ClientCommandHeader header,
InventoryId inventoryId,
ItemId itemId,
ActionId actionId,
Dictionary<string, SnapshotValue> arguments ) =>
Dispatch( header, ClientCommandCosts.ItemAction, () => new RunItemActionCommand( inventoryId, itemId, actionId, arguments ) );
[Rpc.Host]
public void RequestDropItem( ClientCommandHeader header, InventoryId sourceId, ItemId itemId ) =>
Dispatch( header, ClientCommandCosts.DropItem, () => new DropItemCommand( sourceId, itemId ) );
[Rpc.Host]
public void RequestPickupItem( ClientCommandHeader header, ItemId itemId, InventoryId destinationId ) =>
Dispatch( header, ClientCommandCosts.PickupItem, () => new PickUpItemCommand( itemId, destinationId ) );
[Rpc.Host]
public void RequestChat( ClientCommandHeader header, string channelId, string text ) =>
Dispatch( header, ClientCommandCosts.Chat, () => new SendChatCommand( channelId, text ) );
[Rpc.Host]
public void RequestCancelAction( ClientCommandHeader header, Guid instanceId ) =>
Dispatch( header, ClientCommandCosts.CancelAction, () => new CancelActionCommand( instanceId ) );
[Rpc.Host]
public void RequestBeginInteraction(
ClientCommandHeader header,
InteractionTargetInputKind targetKind,
Guid targetId ) =>
Dispatch( header, ClientCommandCosts.BeginInteraction, () => new BeginInteractionCommand(
new InteractionTargetInput( targetKind, targetId ) ) );
[Rpc.Host]
public void RequestContinueInteraction(
ClientCommandHeader header,
InteractionSessionId sessionId,
InteractionTargetInputKind targetKind,
Guid targetId ) =>
Dispatch( header, ClientCommandCosts.ContinueInteraction, () => new ContinueInteractionCommand(
sessionId,
new InteractionTargetInput( targetKind, targetId ) ) );
[Rpc.Host]
public void RequestCloseInteraction( ClientCommandHeader header, InteractionSessionId sessionId ) =>
Dispatch( header, ClientCommandCosts.CloseInteraction, () => new CloseInteractionCommand( sessionId ) );
[Rpc.Host]
public void RequestSchemaCommand( ClientCommandHeader header, string commandId, Dictionary<string, SnapshotValue> arguments )
{
var cost = Runtime?.GetSchemaCommandCost( commandId ) ?? ClientCommandCosts.SchemaCommandFallback;
Dispatch( header, cost, () => new RunSchemaCommandCommand( commandId, arguments ) );
}
public void SendOperationResult(
Connection recipient,
ClientSessionScope scope,
CommandRequestId requestId,
OperationResult result )
{
var retryAfterMilliseconds = OperationResultWireContract.EncodeRetryAfterMilliseconds( result );
using ( Rpc.FilterInclude( recipient ) )
ReceiveOperationResult(
scope,
requestId.Value,
result.Succeeded,
(int)(result.Error?.Code ?? ErrorCode.None),
result.Error?.Message ?? string.Empty,
retryAfterMilliseconds );
}
public void SendCharacterList( Connection recipient, CharacterListSnapshot snapshot )
{
var scope = Runtime?.CaptureClientScope( recipient );
if ( scope is null || scope.Value.Failed ) return;
var encoded = SnapshotWireCodec.EncodeCharacterList( snapshot );
if ( encoded.Failed )
{
LogSnapshotWireFailure( "ENCODE", "character-list", encoded.Error! );
return;
}
using ( Rpc.FilterInclude( recipient ) ) ReceiveCharacterList( scope.Value.Value, encoded.Value );
}
public void SendClientState(
Connection recipient,
PlayerPublicSnapshot publicSnapshot,
PlayerPrivateSnapshot? privateSnapshot,
PlayerRosterSnapshot roster,
IReadOnlyList<SchemaViewSnapshot> schemaViews,
IReadOnlyList<ClientInventorySnapshot> inventories,
ActionProgressSnapshot? activeAction )
{
var runtime = Runtime;
if ( runtime is null ) return;
ClientStateSnapshot.ValidatePayload(
publicSnapshot,
privateSnapshot,
roster,
schemaViews,
inventories,
activeAction );
var unknownPanel = schemaViews.FirstOrDefault( view => !runtime.IsRegisteredPanel( view.PanelId ) );
if ( unknownPanel is not null )
{
Log.Error( $"HEXAGON_CLIENT_STATE_FAILED unknown panel '{unknownPanel.PanelId}'." );
return;
}
var epoch = runtime.PublishClientStateEpoch( recipient, publicSnapshot.CharacterId );
if ( epoch.Failed )
{
Log.Error( $"HEXAGON_CLIENT_STATE_FAILED {epoch.Error!.Message}" );
return;
}
var snapshot = new ClientStateSnapshot(
epoch.Value,
publicSnapshot,
privateSnapshot,
roster,
schemaViews,
inventories,
activeAction );
var scope = runtime.CaptureClientScope( recipient );
if ( scope.Failed ) return;
var encoded = SnapshotWireCodec.EncodeClientState( snapshot );
if ( encoded.Failed )
{
LogSnapshotWireFailure( "ENCODE", "client-state", encoded.Error! );
return;
}
// The replicated [Sync] shell is applied only once every abort exit is behind us,
// so a scope-capture or wire-encode rejection cannot leave the shell ahead of the
// snapshot the client store actually received.
if ( runtime.TryGetPlayer( recipient.Id, out var player ) )
player.HostApplyPublicSnapshot( publicSnapshot );
using ( Rpc.FilterInclude( recipient ) ) ReceiveClientState( scope.Value, encoded.Value );
}
public void SendChat(
Connection recipient,
long revision,
IReadOnlyList<ChatMessageSnapshot> messages )
{
var epoch = Runtime?.CaptureChatDeliveryEpoch( recipient );
if ( epoch is null || epoch.Value.Failed )
{
Log.Error( $"HEXAGON_CHAT_DELIVERY_FAILED {epoch?.Error?.Message ?? "Host runtime is unavailable."}" );
return;
}
var snapshot = new ChatSnapshot( epoch.Value.Value, revision, messages );
var scope = Runtime!.CaptureClientScope( recipient );
if ( scope.Failed ) return;
var encoded = SnapshotWireCodec.EncodeChat( snapshot );
if ( encoded.Failed )
{
LogSnapshotWireFailure( "ENCODE", "chat", encoded.Error! );
return;
}
using ( Rpc.FilterInclude( recipient ) ) ReceiveChat( scope.Value, encoded.Value );
}
private void Dispatch( ClientCommandHeader header, int cost, Func<ClientCommand> commandFactory )
{
var runtime = Runtime;
if ( runtime is null ) return;
// The pipeline itself (charge → resolve → payload-validate → re-resolve → tracked
// host operation → lease re-check → execute → complete) lives in the engine-neutral
// orchestrator so its ordering is compiled and pinned by the unit test suite.
CommandDispatchOrchestrator.Dispatch(
new RuntimeDispatchHost( runtime, this, Rpc.Caller ),
header,
cost,
commandFactory );
}
/// <summary>
/// Engine adapter for the neutral dispatch orchestrator: derives caller identity,
/// forwards host hooks to the runtime, and sends results over the RPC transport.
/// </summary>
private sealed class RuntimeDispatchHost : ICommandDispatchHost<RpcActor>
{
private readonly HexagonRuntimeSystem runtime;
private readonly HexHostServicesComponent _services;
private readonly Connection? _caller;
public RuntimeDispatchHost(
HexagonRuntimeSystem runtime,
HexHostServicesComponent services,
Connection? caller )
{
this.runtime = runtime;
_services = services;
_caller = caller;
}
public bool CallerIsAuthenticated => _caller is not null && _caller.SteamId.ValueUnsigned != 0;
public bool IsExecutionAvailable => runtime.HostApplication is not null;
public CommandAdmissionResult TryBeginCommand( CommandRequestId requestId, int cost ) =>
runtime.TryBeginCommand( _caller!, requestId, cost );
public OperationResult<RpcActor> ResolveActor( ClientSessionScope scope, bool requireStableCharacter ) =>
runtime.ResolveActor( _caller!, scope, requireStableCharacter );
public void FinishRejectedCommand( CommandRequestId requestId ) =>
runtime.FinishRejectedCommand( _caller!, requestId );
public bool IsCommandLeaseCurrent( RpcActor actor ) => runtime.IsCommandLeaseCurrent( actor );
public bool TryStartHostOperation( string name, Func<Task> operation ) =>
runtime.TryStartHostOperation( name, operation );
public CancellationToken CommandCancellation( RpcActor actor ) => actor.CancellationToken;
public ValueTask<OperationResult> ExecuteAsync(
RpcActor actor,
ClientCommand command,
CancellationToken cancellationToken )
{
var application = runtime.HostApplication;
return application is null
? ValueTask.FromResult(
OperationResult.Failure( ErrorCode.InternalError, "Host application is not ready." ) )
: application.HandleCommandAsync( actor, command, cancellationToken );
}
public CommandCompletionStatus CompleteCommand( RpcActor actor, CommandRequestId requestId ) =>
runtime.CompleteCommand( actor, requestId );
public void SendResultToCaller( ClientSessionScope scope, CommandRequestId requestId, OperationResult result )
{
if ( _caller is null ) return;
_services.SendOperationResult( _caller, scope, requestId, result );
}
public void SendResult( RpcActor actor, CommandRequestId requestId, OperationResult result ) =>
_services.SendOperationResult( actor.Connection, actor.ClientScope, requestId, result );
public void OnMalformedPayload( Exception exception ) =>
Log.Warning( exception, "Hexagon rejected a malformed client command payload." );
public void OnExecutionFault( CommandRequestId requestId, Exception exception ) =>
Log.Error( exception, $"Hexagon command '{requestId}' failed unexpectedly." );
public void OnCompletionFault( CommandRequestId requestId, Exception exception ) =>
Log.Error( exception, $"Hexagon could not complete command '{requestId}'." );
}
[Rpc.Broadcast( NetFlags.HostOnly )]
private void ReceiveSessionHello( ClientSessionHello hello )
{
var runtime = HexagonRuntimeSystem.Current;
if ( runtime?.ClientStore?.AcceptHello( hello ) != true ) return;
var transport = runtime.ClientTransport;
if ( transport is null ) return;
if ( transport.BindScope( hello.Scope, out var newlyBound ) && newlyBound )
Log.Info( $"HEXAGON_SESSION_READY client epoch={hello.Scope.Connection}" );
}
[Rpc.Broadcast( NetFlags.HostOnly )]
private void ReceiveSessionRevoked( ClientSessionScope scope )
{
var runtime = HexagonRuntimeSystem.Current;
if ( runtime?.ClientStore?.RevokeSession( scope ) != true ) return;
runtime.ClientTransport?.RevokeScope( scope );
}
[Rpc.Broadcast( NetFlags.HostOnly )]
private void ReceiveOperationResult(
ClientSessionScope scope,
Guid rawRequestId,
bool succeeded,
int errorCode,
string message,
int retryAfterMilliseconds )
{
if ( rawRequestId == Guid.Empty ) return;
var runtime = HexagonRuntimeSystem.Current;
if ( runtime?.ClientStore?.Accepts( scope ) != true ) return;
var result = OperationResultWireContract.Decode(
succeeded, errorCode, message, retryAfterMilliseconds );
runtime.ClientTransport?.Complete( new CommandRequestId( rawRequestId ), result );
}
[Rpc.Broadcast( NetFlags.HostOnly )]
private void ReceiveCharacterList( ClientSessionScope scope, byte[] payload )
{
var store = HexagonRuntimeSystem.Current?.ClientStore;
if ( store?.Accepts( scope ) != true ) return;
var decoded = SnapshotWireCodec.DecodeCharacterList( payload );
if ( decoded.Failed )
{
LogSnapshotWireFailure( "DECODE", "character-list", decoded.Error! );
return;
}
store.ReplaceCharacterList( scope, decoded.Value );
}
[Rpc.Broadcast( NetFlags.HostOnly )]
private void ReceiveClientState( ClientSessionScope scope, byte[] payload )
{
var store = HexagonRuntimeSystem.Current?.ClientStore;
if ( store?.Accepts( scope ) != true ) return;
var decoded = SnapshotWireCodec.DecodeClientState( payload );
if ( decoded.Failed )
{
LogSnapshotWireFailure( "DECODE", "client-state", decoded.Error! );
return;
}
store.ApplyState( scope, decoded.Value );
}
[Rpc.Broadcast( NetFlags.HostOnly )]
private void ReceiveChat( ClientSessionScope scope, byte[] payload )
{
var store = HexagonRuntimeSystem.Current?.ClientStore;
if ( store?.Accepts( scope ) != true ) return;
var decoded = SnapshotWireCodec.DecodeChat( payload );
if ( decoded.Failed )
{
LogSnapshotWireFailure( "DECODE", "chat", decoded.Error! );
return;
}
store.ReplaceChat( scope, decoded.Value );
}
private static void LogSnapshotWireFailure( string direction, string kind, OperationError error ) =>
Log.Error(
$"HEXAGON_SNAPSHOT_WIRE_{direction}_FAILED kind={kind} code={error.Code} message={error.Message}" );
}
public sealed class SandboxClientCommandTransport : IClientCommandTransport, IDisposable
{
private readonly Scene _scene;
private readonly HexClientStore _store;
private readonly ClientSessionNonce _nonce;
private readonly PendingCommandRegistry _pending;
private ClientSessionRetrySchedule? _sessionRetry;
private ClientSessionScope? _scope;
private ClientSessionRetrySchedule SessionRetry =>
_sessionRetry ??= new ClientSessionRetrySchedule( Stopwatch.Frequency );
public SandboxClientCommandTransport(
Scene scene,
HexClientStore store,
ClientSessionNonce nonce,
TimeSpan? timeout = null )
{
_scene = scene ?? throw new ArgumentNullException( nameof(scene) );
_store = store ?? throw new ArgumentNullException( nameof(store) );
_nonce = nonce;
_pending = new PendingCommandRegistry( timeout );
_sessionRetry = new ClientSessionRetrySchedule( Stopwatch.Frequency );
}
internal void PollSession( long timestamp )
{
if ( !SessionRetry.TryBeginAttempt( timestamp ) ) return;
var services = _scene.GetAll<HexHostServicesComponent>().FirstOrDefault();
if ( services is null ) return;
services.RequestEstablishSession( _nonce );
}
internal bool BindScope( ClientSessionScope scope, out bool newlyBound )
{
newlyBound = false;
if ( scope.Nonce != _nonce || !_store.Accepts( scope ) ) return false;
if ( _scope is not null )
{
if ( _scope.Value != scope ) return false;
SessionRetry.Complete();
return true;
}
_scope = scope;
SessionRetry.Complete();
newlyBound = true;
return true;
}
internal bool RevokeScope( ClientSessionScope scope )
{
if ( _scope is null || _scope.Value != scope ) return false;
_scope = null;
SessionRetry.Complete();
_pending.Dispose();
return true;
}
internal bool Complete( CommandRequestId requestId, OperationResult result ) =>
_pending.Complete( requestId, result );
public ValueTask<OperationResult> SendAsync(
ClientCommand command,
System.Threading.CancellationToken cancellationToken = default )
{
cancellationToken.ThrowIfCancellationRequested();
if ( _scope is null || !_store.Accepts( _scope.Value ) )
return ValueTask.FromResult( OperationResult.Failure(
ErrorCode.Unauthorized, "The client session handshake is not complete." ) );
var registered = _pending.Register( cancellationToken );
if ( registered.Failed ) return ValueTask.FromResult(
OperationResult.Failure( registered.Error!.Code, registered.Error.Message ) );
var pending = registered.Value;
var services = _scene.GetAll<HexHostServicesComponent>().FirstOrDefault();
if ( services is null )
{
_pending.Complete( pending.RequestId,
OperationResult.Failure( ErrorCode.InternalError, "Host services are not ready." ) );
return pending.Completion;
}
if ( !_pending.IsPending( pending.RequestId ) ) return pending.Completion;
try
{
var header = new ClientCommandHeader( _scope.Value, pending.RequestId );
switch ( command )
{
case RequestCharacterListCommand:
services.RequestCharacterList( header );
break;
case CreateCharacterCommand create:
services.RequestCreateCharacter(
header,
create.Input.Name,
create.Input.Description,
create.Input.Model,
create.Input.Faction,
create.Input.Class,
new Dictionary<string, SnapshotValue>( create.Input.Fields, StringComparer.Ordinal ) );
break;
case LoadCharacterCommand load:
services.RequestLoadCharacter( header, load.CharacterId );
break;
case DeleteCharacterCommand delete:
services.RequestDeleteCharacter( header, delete.CharacterId );
break;
case UnloadCharacterCommand:
services.RequestUnloadCharacter( header );
break;
case MoveInventoryItemCommand move:
services.RequestMoveItem( header, move.SourceId, move.TargetId, move.ItemId, move.Position );
break;
case RunItemActionCommand action:
services.RequestItemAction(
header,
action.InventoryId,
action.ItemId,
action.ActionId,
new Dictionary<string, SnapshotValue>( action.Arguments, StringComparer.Ordinal ) );
break;
case DropItemCommand drop:
services.RequestDropItem( header, drop.SourceId, drop.ItemId );
break;
case PickUpItemCommand pickup:
services.RequestPickupItem( header, pickup.ItemId, pickup.DestinationId );
break;
case SendChatCommand chat:
services.RequestChat( header, chat.ChannelId, chat.Text );
break;
case CancelActionCommand cancel:
services.RequestCancelAction( header, cancel.InstanceId );
break;
case BeginInteractionCommand interaction:
services.RequestBeginInteraction( header, interaction.Target.Kind, interaction.Target.Id );
break;
case ContinueInteractionCommand interaction:
services.RequestContinueInteraction(
header,
interaction.SessionId,
interaction.Target.Kind,
interaction.Target.Id );
break;
case CloseInteractionCommand interaction:
services.RequestCloseInteraction( header, interaction.SessionId );
break;
case RunSchemaCommandCommand schemaCommand:
services.RequestSchemaCommand(
header,
schemaCommand.CommandId,
new Dictionary<string, SnapshotValue>( schemaCommand.Arguments, StringComparer.Ordinal ) );
break;
default:
_pending.Complete( pending.RequestId,
OperationResult.Failure( ErrorCode.InvalidArgument, "Unknown client command." ) );
break;
}
}
catch ( Exception exception )
{
Log.Error( exception, $"Hexagon could not send command '{pending.RequestId}'." );
_pending.Complete( pending.RequestId,
OperationResult.Failure( ErrorCode.InternalError, "The command could not be sent to the host." ) );
}
return pending.Completion;
}
public void Dispose()
{
SessionRetry.Complete();
_pending.Dispose();
}
}