DeathmatchGame.cs
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

public enum MatchState
{
	IN_PROGRESS,
	ENDED,
}

public sealed class DeathmatchGame : Component
{
	public static DeathmatchGame Instance { get; private set; }

	/// <summary>Match duration in minutes.</summary>
	[ConVar( "match_time", ConVarFlags.GameSetting ), Range( 5, 45 ), Step( 1 )] public static int MatchTime { get; private set; } = 15;

	/// <summary> Time it takes for the match to start in seconds </summary>
	[Property] public float StartTime { get; private set; } = 30f;

	/// <summary>Map package idents available for voting (e.g. sugmagaming.nuketown). Current map is always offered too.</summary>
	[Property] public List<string> MapPool { get; private set; } = [];

	[Property] public SoundEvent HeadshotSound { get; private set; }

	/// <summary>Sounds for multi-kills in quick succession. Index 0 = double kill, 1 = triple kill, etc.</summary>
	[Property] public List<SoundEvent> MultiKillSounds { get; private set; }

	/// <summary>Sounds for killing spree milestones without dying. Index 0 = first milestone, 1 = second, etc.</summary>
	[Property] public List<SoundEvent> KillingSpreeSounds { get; private set; }

	/// <summary>Max seconds between kills to keep a multi-kill chain going.</summary>
	[Property] public float MultiKillWindow { get; set; } = 3f;

	/// <summary>Announce a killing spree every this many kills without dying (e.g. 5 = at 5, 10, 15…).</summary>
	[Property] public int KillingSpreeInterval { get; set; } = 5;

	public float TimeLeftToEnd => MatchEndTime - Time.Now;
	public float TimeLeftToStart => MatchStartTime - Time.Now;

	[Sync] private float MatchEndTime { get; set; }
	[Sync] private float MatchStartTime { get; set; }

	[Sync, Change( nameof( OnStateChange ) )] public MatchState State { get; private set; } = MatchState.ENDED;

	[Sync] public string VoteOption0 { get; private set; } = "";
	[Sync] public string VoteOption1 { get; private set; } = "";
	[Sync] public string VoteOption2 { get; private set; } = "";

	private bool _waitingForMapLoad;
	private bool _intermissionResolved;

	public string GetVoteOption( int index ) => index switch
	{
		0 => VoteOption0,
		1 => VoteOption1,
		2 => VoteOption2,
		_ => ""
	};

	public int GetVoteCount( int index )
	{
		if ( index < 0 || index > 2 || string.IsNullOrEmpty( GetVoteOption( index ) ) )
			return 0;

		return NetworkManager.Instance.PlayerSessions.Count( s => s.IsValid() && s.MapVote == index );
	}

	protected override void OnStart()
	{
		Instance = this;
	}

	protected override void OnDestroy()
	{
		if ( Instance == this )
			Instance = null;
	}

	protected override void OnUpdate()
	{
		if ( IsProxy )
			return;

		UpdateMatchState();
	}

	public void UpdateMatchState()
	{
		if ( State == MatchState.ENDED && TimeLeftToStart <= 0 )
		{
			if ( !_waitingForMapLoad && !_intermissionResolved )
				_ = ResolveIntermissionAsync();
		}
		else if ( State == MatchState.IN_PROGRESS && TimeLeftToEnd <= 0 )
		{
			EndMatch();
		}
	}

	public void StartMatch()
	{
		_waitingForMapLoad = false;
		_intermissionResolved = false;
		ClearVoteOptions();
		State = MatchState.IN_PROGRESS;
		MatchEndTime = Time.Now + MatchTime * 60f;
		//ClearItems();
		NetworkManager.Instance.RespawnAllPlayers();
	}

	public void EndMatch()
	{
		_waitingForMapLoad = false;
		_intermissionResolved = false;
		PickVoteOptions();
		State = MatchState.ENDED;
		MatchStartTime = Time.Now + StartTime;
	}

	private void PickVoteOptions()
	{
		var current = NetworkManager.Instance?.MapName ?? "";
		var options = new List<string>();

		if ( !string.IsNullOrWhiteSpace( current ) )
			options.Add( current );

		var pool = (MapPool ?? [])
			.Where( m => !string.IsNullOrWhiteSpace( m ) )
			.Select( m => m.Trim() )
			.Where( m => !string.Equals( m, current, StringComparison.OrdinalIgnoreCase ) )
			.Distinct( StringComparer.OrdinalIgnoreCase )
			.ToList();

		while ( options.Count < 3 && pool.Count > 0 )
		{
			var pickIndex = Game.Random.Int( 0, pool.Count - 1 );
			options.Add( pool[pickIndex] );
			pool.RemoveAt( pickIndex );
		}

		VoteOption0 = options.ElementAtOrDefault( 0 ) ?? "";
		VoteOption1 = options.ElementAtOrDefault( 1 ) ?? "";
		VoteOption2 = options.ElementAtOrDefault( 2 ) ?? "";
	}

	private async Task ResolveIntermissionAsync()
	{
		if ( _intermissionResolved || _waitingForMapLoad )
			return;

		_intermissionResolved = true;

		var winner = TallyVoteWinner();
		ClearVoteOptions();

		var current = NetworkManager.Instance?.MapName ?? "";
		if ( string.IsNullOrWhiteSpace( winner )
			|| string.Equals( winner, current, StringComparison.OrdinalIgnoreCase ) )
		{
			StartMatch();
			return;
		}

		_waitingForMapLoad = true;
		var ok = await NetworkManager.Instance.SetMap( winner );
		if ( !ok )
		{
			_waitingForMapLoad = false;
			StartMatch();
		}
	}

	private string TallyVoteWinner()
	{
		var options = new[] { VoteOption0, VoteOption1, VoteOption2 };
		var counts = new int[3];

		foreach ( var session in NetworkManager.Instance.PlayerSessions )
		{
			if ( !session.IsValid() )
				continue;

			var vote = session.MapVote;
			if ( vote < 0 || vote > 2 || string.IsNullOrEmpty( options[vote] ) )
				continue;

			counts[vote]++;
		}

		if ( counts.Sum() == 0 )
			return NetworkManager.Instance?.MapName ?? "";

		var max = counts.Max();
		var tied = new List<int>();
		for ( var i = 0; i < 3; i++ )
		{
			if ( counts[i] == max && !string.IsNullOrEmpty( options[i] ) )
				tied.Add( i );
		}

		return options[tied[Game.Random.Int( 0, tied.Count - 1 )]];
	}

	private void ClearVoteOptions()
	{
		VoteOption0 = "";
		VoteOption1 = "";
		VoteOption2 = "";
	}

	private void ClearItems()
	{
		var items = Scene.GetAll<InventoryItem>();
		foreach ( var item in items )
		{
			item.GameObject.Destroy();
		}
	}

	public void HandleSuicide()
	{
		var victimSession = NetworkManager.Instance.GetPlayerSession( Connection.Local.Id );
		if ( !victimSession.IsValid() )
			return;

		victimSession.Deaths++;
		victimSession.ResetKillStreaks();
	}

	public void HandleKill(bool headshot)
	{
		var attackerSession = NetworkManager.Instance.GetPlayerSession( Connection.Local.Id );
		if ( !attackerSession.IsValid() )
			return;

		attackerSession.Kills++;
		attackerSession.KillStreak++;

		Sandbox.Services.Stats.Increment( "kills", 1 );

		if ( headshot )
			PlayHeadshotSound();

		var multiKillCount = attackerSession.RegisterMultiKill( MultiKillWindow );
		if ( multiKillCount >= 2 )
		{
			PlayMultiKillSound( multiKillCount );
			Sandbox.Services.Achievements.Unlock( $"multikill_{multiKillCount}" );
		}

		if ( KillingSpreeInterval > 0 && attackerSession.KillStreak % KillingSpreeInterval == 0 )
		{
			PlayKillingSpreeSound( attackerSession.KillStreak / KillingSpreeInterval );
			Sandbox.Services.Achievements.Unlock( $"killstreak_{attackerSession.KillStreak}" );
		}
	}

	public void HandleDeath( bool isSuicide )
	{
		var victimSession = NetworkManager.Instance.GetPlayerSession( Connection.Local.Id );
		if ( !victimSession.IsValid() )
			return;

		victimSession.Deaths++;
		victimSession.ResetKillStreaks();

		if ( isSuicide )
			victimSession.Kills--;

		Sandbox.Services.Stats.Increment( "deaths", 1 );
	}

	[Rpc.Broadcast]
	private void PlayHeadshotSound()
	{
		Sound.Play( HeadshotSound );
	}

	[Rpc.Broadcast]
	private void PlayMultiKillSound( int multiKillCount )
	{
		var index = multiKillCount - 2;
		if ( index < 0 || index >= MultiKillSounds.Count )
			return;

		Sound.Play( MultiKillSounds[index] );
	}

	[Rpc.Broadcast]
	private void PlayKillingSpreeSound( int milestoneIndex )
	{
		var index = milestoneIndex - 1;
		if ( index < 0 || index >= KillingSpreeSounds.Count )
			return;

		Sound.Play( KillingSpreeSounds[index] );
	}

	[Rpc.Broadcast]
	public void PlayBulletImpactSound( SoundEvent sound, Vector3 position )
	{
		if ( sound is null )
			return;

		Sound.Play( sound, position );
	}

	/// <summary>
	/// Relays a projectile impulse to every peer; only the peer controlling the
	/// target's nearest physics body applies it.
	/// </summary>
	[Rpc.Broadcast]
	public void ApplyProjectileImpulse( GameObject target, Vector3 hitPosition, Vector3 impulse )
	{
		if ( !target.IsValid() )
			return;

		// This is a cosmetic interaction requested by clients. Clamp it so a
		// malformed request cannot launch shared physics objects excessively.
		const float maxImpulse = 500f;
		if ( impulse.Length > maxImpulse )
			impulse = impulse.Normal * maxImpulse;

		var body = FindClosestPhysicsBody( target, hitPosition );
		if ( body is null )
			return;

		// Every peer receives this RPC, but the object's simulator is the only
		// peer allowed to change its physics. Unnetworked fallback is handled by
		// Projectile locally and never reaches this RPC.
		if ( body.Network.Active && body.IsProxy )
			return;

		body.ApplyImpulseAt( body.FindClosestPoint( hitPosition ), impulse );
	}

	private static Rigidbody FindClosestPhysicsBody( GameObject target, Vector3 hitPosition )
	{
		Rigidbody closestBody = null;
		float closestDistanceSquared = float.MaxValue;

		void Consider( Rigidbody candidate )
		{
			if ( !candidate.IsValid() || !candidate.PhysicsBody.IsValid() )
				return;

			var closestPoint = candidate.FindClosestPoint( hitPosition );
			float distanceSquared = closestPoint.DistanceSquared( hitPosition );
			if ( distanceSquared >= closestDistanceSquared )
				return;

			closestBody = candidate;
			closestDistanceSquared = distanceSquared;
		}

		foreach ( var rigidbody in target.Components.GetAll<Rigidbody>( FindMode.EverythingInSelfAndDescendants ) )
			Consider( rigidbody );

		// ModelPhysics keeps ragdoll bodies in an internal collection rather than
		// exposing them as normal descendants.
		foreach ( var modelPhysics in target.Components.GetAll<ModelPhysics>( FindMode.EverythingInSelfAndDescendants ) )
		{
			if ( modelPhysics.Bodies is null )
				continue;

			foreach ( var modelBody in modelPhysics.Bodies )
				Consider( modelBody.Component );
		}

		return closestBody;
	}

	private void OnStateChange( MatchState oldValue, MatchState newValue )
	{
		if ( newValue == MatchState.ENDED)
		{
			var localSession = NetworkManager.Instance?.GetPlayerSession( Connection.Local.Id );
			if ( localSession.IsValid() && !localSession.IsProxy )
				localSession.MapVote = -1;

			if ( NetworkManager.Instance.PlayerSessions.Count > 1 )
			{
				Sandbox.Services.Stats.Increment( "matches", 1 );
				var winner = NetworkManager.Instance.PlayerSessions.OrderByDescending( p => p.Kills ).First();
				if ( winner?.Network.OwnerId == Connection.Local.Id )
					Sandbox.Services.Stats.Increment( "matches_won", 1 );
			}
		}
		else if ( newValue == MatchState.IN_PROGRESS )
		{
			var session = NetworkManager.Instance.GetPlayerSession( Connection.Local.Id );
			session.ClearStats();
		}
	}
}