NetRequest.cs
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading.Tasks;
using Sandbox;
/// <summary>
/// Async request/response over s&box RPCs. RPCs are fire-and-forget with no return values.
/// NetRequest adds typed, awaitable request/response with automatic correlation, timeouts,
/// and error handling. The #1 gap in s&box networking.
/// </summary>
/// <remarks>
/// <para><b>Client:</b></para>
/// <code>var response = await NetRequest.Ask<BuyReq, BuyRes>( new BuyReq { ItemId = "sword" } );</code>
/// <para><b>Host (register once):</b></para>
/// <code>NetRequest.Handle<BuyReq, BuyRes>( (caller, req) => new BuyRes { Success = true } );</code>
/// </remarks>
[Title( "NetKit - Request System" )]
public sealed class NetRequest : GameObjectSystem<NetRequest>
{
private static readonly Dictionary<string, TaskCompletionSource<string>> _pending = new();
private static readonly Dictionary<string, Func<Connection, string, string>> _handlers = new();
/// <summary>Default timeout in seconds. Requests that exceed this resolve with default(TResponse).</summary>
public static float TimeoutSeconds { get; set; } = 10f;
public NetRequest( Scene scene ) : base( scene )
{
Log.Info( "[NetKit] Request system online" );
}
/// <summary>
/// Send a typed request to the host and await a typed response.
/// </summary>
public static async Task<TResponse> Ask<TRequest, TResponse>( TRequest request )
{
var requestId = Guid.NewGuid().ToString( "N" );
var typeName = typeof( TRequest ).FullName;
var json = JsonSerializer.Serialize( request );
var tcs = new TaskCompletionSource<string>();
_pending[requestId] = tcs;
RpcSendRequest( typeName, requestId, json );
// Poll for completion — Task.WhenAny is blocked by s&box's security whitelist.
var elapsed = 0f;
while ( !tcs.Task.IsCompleted && elapsed < TimeoutSeconds )
{
await GameTask.Delay( 50 );
elapsed += 0.05f;
}
_pending.Remove( requestId );
if ( tcs.Task.IsCompleted && tcs.Task.Result != null )
{
try
{
return JsonSerializer.Deserialize<TResponse>( tcs.Task.Result );
}
catch ( Exception ex )
{
Log.Warning( $"[NetKit] Failed to deserialize response for {typeName}: {ex.Message}" );
return default;
}
}
Log.Warning( $"[NetKit] Request {typeName} timed out after {TimeoutSeconds}s" );
return default;
}
/// <summary>
/// Register a handler for a request type. The handler runs on the host and returns
/// a response sent back to the calling client.
/// </summary>
public static void Handle<TRequest, TResponse>( Func<Connection, TRequest, TResponse> handler )
{
var typeName = typeof( TRequest ).FullName;
_handlers[typeName] = ( caller, json ) =>
{
var request = JsonSerializer.Deserialize<TRequest>( json );
var response = handler( caller, request );
return JsonSerializer.Serialize( response );
};
}
[Rpc.Host( NetFlags.Reliable )]
private static void RpcSendRequest( string typeName, string requestId, string json )
{
var caller = Rpc.Caller;
if ( caller == null ) return;
if ( !_handlers.TryGetValue( typeName, out var handler ) )
{
Log.Warning( $"[NetKit] No handler for {typeName}" );
using ( Rpc.FilterInclude( caller ) )
RpcSendResponse( requestId, "{}" );
return;
}
string responseJson;
try
{
responseJson = handler( caller, json );
}
catch ( Exception ex )
{
Log.Warning( $"[NetKit] Handler for {typeName} threw: {ex.Message}" );
responseJson = "{}";
}
using ( Rpc.FilterInclude( caller ) )
RpcSendResponse( requestId, responseJson );
}
[Rpc.Owner( NetFlags.Reliable )]
private static void RpcSendResponse( string requestId, string json )
{
if ( _pending.TryGetValue( requestId, out var tcs ) )
tcs.TrySetResult( json );
}
}