using System;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
namespace codelanguage
{
/// One declared argument of a remote verb.
public sealed class RemoteVerbParameter
{
public string Name { get; set; } = string.Empty;
public string Type { get; set; } = string.Empty;
}
/// A verb advertised by a hosted world for the player's mob type.
public sealed class RemoteVerb
{
public string Name { get; set; } = string.Empty;
public string Category { get; set; } = string.Empty;
public List Parameters { get; set; } = new();
}
///
/// Lightweight client-side network session. It wires a transport connection
/// to ClientReplicationManager, keeps authentication/connection state, and
/// provides the heartbeat messages and lifecycle events needed by a client
/// world renderer such as CraftSeeker.
///
public class ClientNetworkSession : IDisposable
{
private readonly CancellationTokenSource _lifetime = new CancellationTokenSource();
private CancellationTokenSource? _heartbeatCancellation;
private Task? _heartbeatTask;
private TaskCompletionSource? _pendingLogin;
private readonly object _pendingLoginLock = new object();
private readonly object _connectionLifecycleLock = new object();
private Task? _disconnectLifecycleTask;
private volatile bool _disconnectLifecycleStarted;
public NetworkClient Client { get; }
public ClientReplicationManager Replication { get; }
public ClientAuthenticationState Authentication { get; }
public ClientConnectionState Connection { get; }
public event Action? MessageReceived;
public event Action? ObjectSpawned;
public event Action? ObjectUpdated;
public event Action? ObjectDespawned;
public event Action? Connected;
public event Action? Disconnected;
/// Raised when a chunked world resource bundle from the server
/// has been fully reassembled and verified.
public event Action? ResourceBundleReceived;
/// Completed world resource bundle (.rsc) downloaded from the
/// server, if any. Also set before
/// subscribers attach, so callers can check it after login returns.
public byte[]? ResourceBundle { get; private set; }
private readonly ResourceBundleTransfer.Receiver _resourceBundleReceiver = new();
/// Verbs advertised by the hosted world for this connection's
/// player mob (populated from the LoginResponse). A remote Seeker renders
/// these instead of walking its own local TypeRegistry, which does not
/// contain the world's types.
public IReadOnlyList RemoteVerbs { get; private set; } = Array.Empty();
public ClientNetworkSession(
NetworkClient? client = null,
ClientReplicationManager? replication = null,
ClientConnectionState? connection = null,
ClientAuthenticationState? authentication = null)
{
Client = client ?? new NetworkClient();
Replication = replication ?? new ClientReplicationManager();
Connection = connection ?? new ClientConnectionState();
Authentication = authentication ?? new ClientAuthenticationState();
Client.MessageReceived += HandleTransportMessage;
Client.Disconnected += HandleTransportDisconnected;
Replication.ObjectSpawned += replicatedObject => InvokeSafely(ObjectSpawned, replicatedObject);
Replication.ObjectUpdated += replicatedObject => InvokeSafely(ObjectUpdated, replicatedObject);
Replication.ObjectDespawned += objectId => InvokeSafely(ObjectDespawned, objectId);
}
public bool IsConnected => Connection.IsConnected;
public bool IsAuthenticated => Authentication.IsAuthenticated;
public int ClientId => Connection.ClientId;
public int PlayerObjectId { get; private set; }
public TimeSpan HeartbeatInterval { get; set; } = TimeSpan.FromMilliseconds(NetworkConfig.HeartbeatIntervalMs);
public async Task ConnectAsync(string host, int port, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(host))
throw new ArgumentException("The server host must be supplied.", nameof(host));
if (port <= 0 || port > 65535)
throw new ArgumentOutOfRangeException(nameof(port));
if (Connection.IsConnected)
return true;
Connection.ServerAddress = host;
Connection.ServerPort = port;
ResourceBundle = null;
_resourceBundleReceiver.Reset();
RemoteVerbs = Array.Empty();
await Client.ConnectAsync(host, port, cancellationToken).ConfigureAwait(false);
lock (_connectionLifecycleLock)
{
_disconnectLifecycleTask = null;
_disconnectLifecycleStarted = false;
}
_heartbeatCancellation?.Dispose();
_heartbeatCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _lifetime.Token);
Connection.IsConnected = true;
Connection.ConnectedAt = DateTime.UtcNow;
Connection.LastHeartbeat = DateTime.UtcNow;
InvokeSafely(Connected);
_heartbeatTask = HeartbeatLoopAsync(_heartbeatCancellation.Token);
return true;
}
public Task LoginAsync(string username, string password, CancellationToken cancellationToken = default) =>
LoginCoreAsync(username, password, null, cancellationToken);
public Task LoginWithHubSessionAsync(string username, string hubSessionToken,
CancellationToken cancellationToken = default) =>
LoginCoreAsync(username, null, hubSessionToken, cancellationToken);
private async Task LoginCoreAsync(string username, string? password, string? hubSessionToken,
CancellationToken cancellationToken)
{
if (!Connection.IsConnected)
throw new InvalidOperationException("The client must be connected before logging in.");
TaskCompletionSource pendingLogin;
lock (_pendingLoginLock)
{
if (_pendingLogin != null && !_pendingLogin.Task.IsCompleted)
throw new InvalidOperationException("A login request is already pending.");
_pendingLogin = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
pendingLogin = _pendingLogin;
}
if (string.IsNullOrWhiteSpace(hubSessionToken))
Client.SendLoginRequest(username, password ?? string.Empty);
else
Client.SendHubLoginRequest(username, hubSessionToken);
NetworkMessage response;
try
{
Task timeoutTask = Task.Delay(NetworkConfig.HeartbeatTimeoutMs, cancellationToken);
Task completedTask = await Task.WhenAny(pendingLogin.Task, timeoutTask).ConfigureAwait(false);
if (completedTask != pendingLogin.Task)
if (cancellationToken.IsCancellationRequested)
throw new OperationCanceledException(cancellationToken);
else
throw new TimeoutException("The login response was not received before the heartbeat timeout expired.");
response = await pendingLogin.Task.ConfigureAwait(false);
}
catch
{
lock (_pendingLoginLock)
{
if (_pendingLogin == pendingLogin)
_pendingLogin = null;
}
throw;
}
lock (_pendingLoginLock)
{
_pendingLogin = null;
}
bool success = Client.ApplyLoginResponse(response);
if (success)
{
Connection.IsAuthenticated = true;
// Account IDs and transport connection IDs are different namespaces.
}
else
{
Connection.IsAuthenticated = false;
Authentication.ClearAuthentication();
}
return success;
}
public void HandleMessage(NetworkMessage message)
{
if (message == null)
throw new ArgumentNullException(nameof(message));
HandleTransportMessage(message);
}
public bool ApplyMessage(NetworkMessage message)
{
HandleMessage(message);
return true;
}
public void ProcessMessage(NetworkMessage message) => HandleMessage(message);
public void ReceiveMessage(NetworkMessage message) => HandleMessage(message);
/// Sends a verb/proc invocation to the server for this
/// connection's player mob. The result (or an error) arrives as
/// ProcReturn/ProcError and is stored in 's RPC
/// table under the returned call id. Fire-and-forget is the normal verb
/// pattern; callers that need the result can poll GetResponse(callId).
public long SendProcedureCall(string procName, params object?[] args)
=> SendProcedureCall(PlayerObjectId, procName, args);
/// Sends a verb/proc invocation targeted at a specific replicated
/// object (0 or the player's own id targets the player mob itself).
public long SendProcedureCall(int targetObjectId, string procName, params object?[] args)
{
if (string.IsNullOrWhiteSpace(procName))
throw new ArgumentException("A procedure name is required.", nameof(procName));
if (!Connection.IsAuthenticated)
throw new InvalidOperationException("The session must be authenticated before calling world procedures.");
long callId = Replication.Rpc.GenerateCallId();
Client.Send(Replication.Rpc.CreateCallMessage(callId, procName, targetObjectId, args ?? Array.Empty