Code/V2/Runtime/PlayerInputAdmission.cs

Input handling and validation utilities for player movement and prediction. Defines input enums, immutable input frame structs, admission/ratelimiting logic, authentication checks, prediction reconciliation rules, and deterministic acceptance rules for client inputs.

NetworkingFile Access
#nullable enable

using System;

namespace Hexagon.V2.Runtime;

[Flags]
public enum PlayerInputButtons : byte
{
	None = 0,
	Run = 1 << 0,
	Duck = 1 << 1,
	Jump = 1 << 2
}

/// <summary>
/// Fixed-size, untrusted client movement sample. The host validates every field
/// before it can influence the canonical player controller.
/// </summary>
public readonly record struct PlayerInputFrame(
	int BodyGeneration,
	uint Sequence,
	float MoveX,
	float MoveY,
	float Pitch,
	float Yaw,
	PlayerInputButtons Buttons,
	uint JumpSequence );

public enum PlayerInputAdmissionFailure
{
	None = 0,
	RateLimited = 1,
	StaleBodyGeneration = 2,
	Replay = 3,
	Malformed = 4,
	InvalidButtons = 5,
	InvalidMove = 6,
	InvalidPitch = 7,
	StaleJumpSequence = 8
}

public static class PlayerInputAuthorityRules
{
	public const double IdleTimeoutSeconds = 0.250;
	public const double JumpCooldownSeconds = 0.500;

	public static bool IsInputFresh( bool hasInput, double acceptedAtSeconds, double nowSeconds ) =>
		hasInput && double.IsFinite( acceptedAtSeconds ) && double.IsFinite( nowSeconds ) &&
		nowSeconds >= acceptedAtSeconds && nowSeconds - acceptedAtSeconds <= IdleTimeoutSeconds;

	public static bool CanJump(
		bool isGrounded,
		float jumpSpeed,
		double lastJumpAtSeconds,
		double nowSeconds ) =>
		isGrounded && jumpSpeed > 0 && double.IsFinite( nowSeconds ) &&
		nowSeconds - lastJumpAtSeconds >= JumpCooldownSeconds;
}

public static class PlayerBodyAuthorityRules
{
	public static bool IsUsable(
		bool hasActiveCharacter,
		bool isDead,
		bool bodyIsValid,
		bool bodyIsEnabled ) =>
		hasActiveCharacter && !isDead && bodyIsValid && bodyIsEnabled;
}

public readonly record struct PlayerInputAuthenticationState(
	bool SessionExists,
	bool ExactPlayerInstance,
	bool ApplicationConnected,
	Guid CanonicalCharacterId,
	Guid ShellCharacterId,
	int CurrentBodyGeneration,
	int FrameBodyGeneration,
	bool BodyUsable );

public static class PlayerInputSessionAuthentication
{
	public static bool IsAuthorized( PlayerInputAuthenticationState state ) =>
		state.SessionExists && state.ExactPlayerInstance && state.ApplicationConnected &&
		state.CanonicalCharacterId != Guid.Empty &&
		state.CanonicalCharacterId == state.ShellCharacterId &&
		state.CurrentBodyGeneration == state.FrameBodyGeneration && state.BodyUsable;
}

public enum PredictionCorrectionKind
{
	Ignore = 0,
	Smooth = 1,
	Hard = 2
}

public readonly record struct PredictionPosition( float X, float Y, float Z )
{
	public bool IsFinite => float.IsFinite( X ) && float.IsFinite( Y ) && float.IsFinite( Z );
}

public readonly record struct PredictionCorrectionPlan(
	PredictionCorrectionKind Kind,
	PredictionPosition Delta );

public static class PredictionReconciliation
{
	public const float IgnoreDistance = 2f;
	public const float HardCorrectionDistance = 48f;

	public static PredictionCorrectionKind Decide( bool historyFound, float errorDistance )
	{
		if ( !historyFound || !float.IsFinite( errorDistance ) || errorDistance < 0 ||
			errorDistance > HardCorrectionDistance ) return PredictionCorrectionKind.Hard;
		return errorDistance > IgnoreDistance
			? PredictionCorrectionKind.Smooth
			: PredictionCorrectionKind.Ignore;
	}

	/// <summary>
	/// Computes correction against the predicted position captured for the exact
	/// processed input sequence. When history is present, applying the returned
	/// delta to the current predictor preserves motion simulated after that input.
	/// Missing history hard-corrects current prediction to the authoritative state.
	/// </summary>
	public static PredictionCorrectionPlan Calculate(
		bool historyFound,
		PredictionPosition authoritativeAtSequence,
		PredictionPosition predictedAtSequence,
		PredictionPosition currentPrediction )
	{
		if ( !authoritativeAtSequence.IsFinite || !currentPrediction.IsFinite ||
			(historyFound && !predictedAtSequence.IsFinite) )
			return new PredictionCorrectionPlan(
				PredictionCorrectionKind.Hard,
				new PredictionPosition( 0, 0, 0 ) );

		var baseline = historyFound ? predictedAtSequence : currentPrediction;
		var delta = new PredictionPosition(
			authoritativeAtSequence.X - baseline.X,
			authoritativeAtSequence.Y - baseline.Y,
			authoritativeAtSequence.Z - baseline.Z );
		var distance = MathF.Sqrt(
			delta.X * delta.X + delta.Y * delta.Y + delta.Z * delta.Z );
		return new PredictionCorrectionPlan( Decide( historyFound, distance ), delta );
	}
}

/// <summary>
/// Deterministic per-session admission boundary. Tokens are consumed before
/// validation so malformed traffic cannot evade the rate limit.
/// </summary>
public sealed class PlayerInputAdmission
{
	public const double DefaultRatePerSecond = 60.0;
	public const double DefaultBurst = 15.0;
	private const PlayerInputButtons KnownButtons =
		PlayerInputButtons.Run | PlayerInputButtons.Duck | PlayerInputButtons.Jump;

	private readonly double _ratePerSecond;
	private readonly double _burst;
	private double _tokens;
	private double _lastRefillSeconds;
	private bool _hasClock;
	private bool _hasAcceptedSequence;
	private uint _lastAcceptedSequence;
	private bool _hasAcceptedJumpSequence;
	private uint _lastAcceptedJumpSequence;

	public PlayerInputAdmission(
		double ratePerSecond = DefaultRatePerSecond,
		double burst = DefaultBurst )
	{
		if ( !double.IsFinite( ratePerSecond ) || ratePerSecond <= 0 )
			throw new ArgumentOutOfRangeException( nameof(ratePerSecond) );
		if ( !double.IsFinite( burst ) || burst < 1 )
			throw new ArgumentOutOfRangeException( nameof(burst) );
		_ratePerSecond = ratePerSecond;
		_burst = burst;
		_tokens = burst;
	}

	public bool TryAccept(
		PlayerInputFrame frame,
		int expectedBodyGeneration,
		double nowSeconds,
		out PlayerInputFrame accepted,
		out PlayerInputAdmissionFailure failure )
	{
		if ( !TryBeginAttempt( nowSeconds, out failure ) )
		{
			accepted = default;
			return false;
		}
		return TryAcceptCharged( frame, expectedBodyGeneration, out accepted, out failure );
	}

	/// <summary>Charges one attempt before caller/session/body validation.</summary>
	public bool TryBeginAttempt( double nowSeconds, out PlayerInputAdmissionFailure failure )
	{
		if ( TryCharge( nowSeconds ) )
		{
			failure = PlayerInputAdmissionFailure.None;
			return true;
		}
		failure = PlayerInputAdmissionFailure.RateLimited;
		return false;
	}

	internal bool TryAcceptCharged(
		PlayerInputFrame frame,
		int expectedBodyGeneration,
		out PlayerInputFrame accepted,
		out PlayerInputAdmissionFailure failure )
	{
		accepted = default;
		if ( frame.BodyGeneration != expectedBodyGeneration )
		{
			failure = PlayerInputAdmissionFailure.StaleBodyGeneration;
			return false;
		}
		if ( _hasAcceptedSequence && !IsNewer( frame.Sequence, _lastAcceptedSequence ) )
		{
			failure = PlayerInputAdmissionFailure.Replay;
			return false;
		}
		if ( !float.IsFinite( frame.MoveX ) || !float.IsFinite( frame.MoveY ) ||
			!float.IsFinite( frame.Pitch ) || !float.IsFinite( frame.Yaw ) )
		{
			failure = PlayerInputAdmissionFailure.Malformed;
			return false;
		}
		if ( (frame.Buttons & ~KnownButtons) != 0 )
		{
			failure = PlayerInputAdmissionFailure.InvalidButtons;
			return false;
		}
		var moveLengthSquared = frame.MoveX * frame.MoveX + frame.MoveY * frame.MoveY;
		if ( moveLengthSquared > 1.0002f )
		{
			failure = PlayerInputAdmissionFailure.InvalidMove;
			return false;
		}
		if ( frame.Pitch is < -90f or > 90f )
		{
			failure = PlayerInputAdmissionFailure.InvalidPitch;
			return false;
		}
		if ( _hasAcceptedJumpSequence && frame.JumpSequence != _lastAcceptedJumpSequence &&
			!IsNewer( frame.JumpSequence, _lastAcceptedJumpSequence ) )
		{
			failure = PlayerInputAdmissionFailure.StaleJumpSequence;
			return false;
		}

		accepted = frame with { Yaw = NormalizeYaw( frame.Yaw ) };
		_lastAcceptedSequence = frame.Sequence;
		_hasAcceptedSequence = true;
		_lastAcceptedJumpSequence = frame.JumpSequence;
		_hasAcceptedJumpSequence = true;
		failure = PlayerInputAdmissionFailure.None;
		return true;
	}

	public void Reset()
	{
		_tokens = _burst;
		_hasClock = false;
		_hasAcceptedSequence = false;
		_lastAcceptedSequence = 0;
		_hasAcceptedJumpSequence = false;
		_lastAcceptedJumpSequence = 0;
	}

	public static bool IsNewer( uint candidate, uint current ) =>
		candidate != current && unchecked((int)(candidate - current)) > 0;

	public static float NormalizeYaw( float yaw )
	{
		var normalized = yaw % 360f;
		if ( normalized >= 180f ) normalized -= 360f;
		if ( normalized < -180f ) normalized += 360f;
		return normalized;
	}

	private bool TryCharge( double nowSeconds )
	{
		if ( !double.IsFinite( nowSeconds ) ) return false;
		if ( !_hasClock )
		{
			_lastRefillSeconds = nowSeconds;
			_hasClock = true;
		}
		else if ( nowSeconds > _lastRefillSeconds )
		{
			_tokens = Math.Min( _burst, _tokens + (nowSeconds - _lastRefillSeconds) * _ratePerSecond );
			_lastRefillSeconds = nowSeconds;
		}
		if ( _tokens < 1.0 ) return false;
		_tokens -= 1.0;
		return true;
	}
}