Game/BanSystem/BanSystem.cs
using Sandbox.UI;

/// <summary>
/// Holds a banlist, can ban users
/// </summary>
public sealed class BanSystem : GameObjectSystem<BanSystem>, Component.INetworkListener
{
	public record struct BanEntry( string DisplayName, string Reason );

	private Dictionary<long, BanEntry> _bans = new();

	public BanSystem( Scene scene ) : base( scene )
	{
		_bans = LocalData.Get<Dictionary<long, BanEntry>>( "bans", new() ) ?? new();
	}

	bool Component.INetworkListener.AcceptConnection( Connection connection, ref string reason )
	{
		if ( !_bans.TryGetValue( connection.SteamId, out var entry ) )
			return true;

		reason = $"You're banned from this server: {entry.Reason}";
		return false;
	}

	/// <summary>
	/// Bans a connected player and kicks them immediately
	/// </summary>
	public void Ban( Connection connection, string reason )
	{
		Assert.True( Networking.IsHost, "Only the host may ban players." );

		_bans[connection.SteamId] = new BanEntry( connection.DisplayName, reason );
		Save();

		GameManager.Current.Notify( $"🔨 {connection.DisplayName} was banned: {reason}" );

		connection.Kick( $"Banned: {reason}" );
	}

	/// <summary>
	/// Bans a Steam ID by value. Use for pre-banning or banning players who are not currently connected.
	/// Display name falls back to the Steam ID string.
	/// </summary>
	public void Ban( SteamId steamId, string reason )
	{
		Assert.True( Networking.IsHost, "Only the host may ban players." );

		_bans[steamId] = new BanEntry( steamId.ToString(), reason );
		Save();
	}

	/// <summary>
	/// Removes the ban for the given Steam ID.
	/// </summary>
	public void Unban( SteamId steamId )
	{
		Assert.True( Networking.IsHost, "Only the host may unban players." );

		if ( _bans.Remove( steamId ) )
			Save();
	}

	/// <summary>
	/// Returns true if the given Steam ID is currently banned
	/// </summary>
	public bool IsBanned( SteamId steamId ) => _bans.ContainsKey( steamId );

	/// <summary>
	/// Returns a read-only view of all active bans
	/// </summary>
	public IReadOnlyDictionary<SteamId, BanEntry> GetBannedList() => _bans.ToDictionary( x => (SteamId)x.Key, x => x.Value );

	private void Save() => LocalData.Set( "bans", _bans );

	/// <summary>
	/// RPC to ban a connected player. Caller must be host or have admin permission.
	/// </summary>
	[Rpc.Host]
	internal static void RpcBanPlayer( Connection target, string reason = "Banned" )
	{
		if ( !Rpc.Caller.HasPermission( "admin" ) ) return;

		Current.Ban( target, reason );
	}

	/// <summary>
	/// Bans a player by name or Steam ID. Optionally provide a reason.
	/// Usage: ban [name|steamid] [reason]
	/// </summary>
	[ConCmd( "ban" )]
	internal static void BanCommand( string target, string reason = "Banned" )
	{
		if ( !Networking.IsHost ) return;

		// Try parsing as a Steam ID (64-bit integer) first
		if ( ulong.TryParse( target, out var steamIdValue ) )
		{
			var steamId = steamIdValue;
			var connection = Connection.All.FirstOrDefault( c => c.SteamId == steamId );

			if ( connection is not null )
				Current.Ban( connection, reason );
			else
				Current.Ban( steamId, reason );

			Log.Info( $"Banned {steamId}: {reason}" );
			return;
		}

		// Fall back to partial name match
		var conn = GameManager.FindPlayerWithName( target );
		if ( conn is not null )
		{
			Current.Ban( conn, reason );
			Log.Info( $"Banned {conn.DisplayName}: {reason}" );
		}
		else
		{
			Log.Warning( $"Could not find player '{target}'" );
		}
	}
}