GameManager/PilotGame.cs
/// <summary>
/// Main game manager for FP4. Handles player connections, spawn logic, 
/// game mode selection, and global RPC routing.
/// </summary>
public sealed class PilotGame : GameObjectSystem<PilotGame>, Component.INetworkListener, ISceneStartup
{
	public static FPGameMode Gamemode => GameSettings.Gamemode;
	public static bool MatchEnded { get; private set; } = false;
	public static bool MatchWasDraw { get; private set; } = false;
	public static string WinnerName { get; private set; } = "";
	public static string MatchEndReason { get; private set; } = "";
	public static float MatchTimeRemaining { get; private set; } = 0f;

	private float _matchStartTime;
	private float _lastTimeRemaining;
	private float _restartAtTime = -1f;

	public PilotGame( Scene scene ) : base( scene )
	{
		Listen( Stage.FinishUpdate, 0, HostFixedUpdate, "HostFixedUpdate" );
		_matchStartTime = Time.Now;
		_lastTimeRemaining = GetConfiguredMatchLengthSeconds();
		MatchTimeRemaining = _lastTimeRemaining;
	}

	private void HostFixedUpdate()
	{
		if ( !Networking.IsHost ) return;

		UpdateMatchRules();

		foreach ( var pawn in Game.ActiveScene.GetAllComponents<PlayerPawn>() )
			pawn.HostFixedUpdate();
	}

	void ISceneStartup.OnHostInitialize()
	{
		if ( !Networking.IsActive )
		{
			Networking.CreateLobby( new Sandbox.Network.LobbyConfig
			{
				MaxPlayers = 16,
				Privacy = Sandbox.Network.LobbyPrivacy.Public
			} );
		}
	}

	// -------------------------------------------------------------------------
	// INetworkListener
	// -------------------------------------------------------------------------

	public void OnActive( Connection channel )
	{
		Log.Info( $"[PilotGame] OnActive: {channel.DisplayName}" );

		channel.CanSpawnObjects = false;

		var prefab = ResourceLibrary.Get<PrefabFile>( "prefabs/player/player.prefab" );
		if ( prefab == null )
		{
			Log.Error( "[PilotGame] player.prefab not found in ResourceLibrary!" );
			return;
		}

		var playerGo = SceneUtility.GetPrefabScene( prefab )?.Clone();
		if ( playerGo == null )
		{
			Log.Error( "[PilotGame] Failed to clone player prefab!" );
			return;
		}

		playerGo.Name = $"Player ({channel.DisplayName})";

		var pawn = playerGo.GetComponent<PlayerPawn>( true );
		if ( pawn == null )
		{
			Log.Error( "[PilotGame] No PlayerPawn found in player prefab!" );
			playerGo.Destroy();
			return;
		}

		// Set spawn position BEFORE NetworkSpawn — client receives the correct initial transform.
		// Can't set it after since the client owns the Rigidbody and would overwrite any host change.
		var spawn = GetRandomSpawnpoint();
		playerGo.WorldPosition = spawn.Position;
		playerGo.WorldRotation = spawn.Rotation;

		playerGo.NetworkSpawn( channel );

		// In instagib, skip ship select — auto-assign the locked ship and spawn immediately.
		if ( Gamemode == FPGameMode.Instagib )
		{
			var shipData = ResourceLibrary.Get<ShipData>( "ships/wingsship.ship" );
			if ( shipData != null )
			{
				pawn.Data = shipData;
				pawn.HasSelectedShip = true;
				pawn.TriggerRespawn( 0.1f );
			}
		}

		// In normal mode: pawn.IsAlive stays false until RequestNewShip is called from ship select.

		BroadcastChat( channel.DisplayName, "joined the game." );
		Log.Info( $"[PilotGame] Spawned pawn for {channel.DisplayName} — IsAlive={pawn.IsAlive}" );
	}

	public void OnDisconnected( Connection channel )
	{
		BroadcastChat( channel.DisplayName, "disconnected." );
	}

	// -------------------------------------------------------------------------
	// Chat
	// -------------------------------------------------------------------------

	[ConCmd( "fp4_chat" )]
	public static void SendChat( string message )
	{
		if ( !Networking.IsHost ) return;
		BroadcastChat( Rpc.Caller.DisplayName, message );
	}

	[Rpc.Broadcast]
	public static void BroadcastChat( string name, string message )
	{
		ChatEvent.Run( name, message );
	}

	// -------------------------------------------------------------------------
	// Kill Feed
	// -------------------------------------------------------------------------

	[Rpc.Broadcast]
	public static void BroadcastKillMessage( string killer, string victim, string method )
	{
		KillFeedEvent.Run( killer, victim, method );
	}

	// -------------------------------------------------------------------------
	// Ship selection
	// -------------------------------------------------------------------------

	[ConCmd( "fp4_newship" )]
	public static void PlayerNewShip( string newShip )
	{
		// Legacy console command - clients should use PlayerPawn.RequestNewShip() instead
		// Kept for server admin use
		if ( !Networking.IsHost ) return;

		var caller = Rpc.Caller;
		foreach ( var pawn in Game.ActiveScene.GetAllComponents<PlayerPawn>() )
		{
			if ( pawn.Network.Owner == caller )
			{
				pawn.RequestNewShip( newShip );
				return;
			}
		}
	}

	[ConCmd( "fp4_suicide" )]
	public static void DoPlayerSuicide()
	{
		// Legacy console command - clients should use PlayerPawn.RequestSuicide() instead
		if ( !Networking.IsHost ) return;

		var caller = Rpc.Caller;
		foreach ( var pawn in Game.ActiveScene.GetAllComponents<PlayerPawn>() )
		{
			if ( pawn.Network.Owner == caller )
			{
				pawn.TakeDamage( 1000f, null );
				return;
			}
		}
	}

	[ConCmd( "fp4_addbot" )]
	public static void AddBot()
	{
		if ( !Networking.IsHost ) return;
		GameSettings.BotCount++;
		GetBotSpawner()?.AddBot();
	}

	[ConCmd( "fp4_removebot" )]
	public static void RemoveBot()
	{
		if ( !Networking.IsHost ) return;
		GameSettings.BotCount = Math.Max( 0, GameSettings.BotCount - 1 );
		GetBotSpawner()?.RemoveBot();
	}

	private static BotSpawner GetBotSpawner() =>
		Game.ActiveScene?.GetAllComponents<BotSpawner>().FirstOrDefault();

	private void UpdateMatchRules()
	{
		if ( MatchEnded )
		{
			if ( _restartAtTime > 0f && Time.Now >= _restartAtTime )
				RestartMatch();
			return;
		}

		// Time limit
		var matchLengthSeconds = GetConfiguredMatchLengthSeconds();
		if ( matchLengthSeconds > 0f )
		{
			var elapsed = Time.Now - _matchStartTime;
			MatchTimeRemaining = MathF.Max( 0f, matchLengthSeconds - elapsed );

			if ( MatchTimeRemaining <= 0f )
			{
				EndMatchFromTopScore( "Time limit reached" );
				return;
			}
		}
		else
		{
			MatchTimeRemaining = 0f;
		}

		// Score limit
		var scoreLimit = Math.Max( 0, GameSettings.MatchScoreLimit );
		if ( scoreLimit > 0 )
		{
			foreach ( var pawn in Game.ActiveScene.GetAllComponents<PlayerPawn>() )
			{
				if ( pawn == null || !pawn.IsValid() ) continue;
				if ( pawn.Score >= scoreLimit )
				{
					EndMatchFromTopScore( $"Reached {scoreLimit} points" );
					return;
				}
			}
		}
	}

	public static bool CanScoreAndDamage() => !MatchEnded;

	private void EndMatchFromTopScore( string reason )
	{
		var contenders = Game.ActiveScene.GetAllComponents<PlayerPawn>()
			.Where( p => p != null && p.IsValid() )
			.ToList();
		if ( contenders.Count == 0 ) return;

		var topScore = contenders.Max( p => p.Score );
		var top = contenders.Where( p => p.Score == topScore ).ToList();

		if ( top.Count > 1 )
		{
			EndMatchDraw( reason );
			return;
		}

		EndMatch( top[0], reason );
	}

	private void EndMatch( PlayerPawn winner, string reason )
	{
		if ( MatchEnded || winner == null ) return;

		MatchEnded = true;
		MatchWasDraw = false;
		MatchEndReason = reason ?? "Match ended";
		WinnerName = winner.IsBot
			? (string.IsNullOrWhiteSpace( winner.BotName ) ? "Bot" : winner.BotName)
			: (winner.Network?.Owner?.DisplayName ?? "Player");

		var delay = Math.Max( 3, GameSettings.MatchRestartDelaySeconds );
		_restartAtTime = Time.Now + delay;

		// Match winner bonus XP (persistent stat; skipped for bots by AddStat).
		winner.AddStat( "xp", 50 );

		foreach ( var pawn in Game.ActiveScene.GetAllComponents<PlayerPawn>() )
		{
			if ( pawn == null || !pawn.IsValid() ) continue;
			pawn.OnMatchCompleted( pawn == winner );
		}

		BroadcastChat( "SYSTEM", $"{WinnerName} wins! {MatchEndReason}" );
	}

	private void EndMatchDraw( string reason )
	{
		if ( MatchEnded ) return;

		MatchEnded = true;
		MatchWasDraw = true;
		WinnerName = "";
		MatchEndReason = reason ?? "Match ended";

		var delay = Math.Max( 3, GameSettings.MatchRestartDelaySeconds );
		_restartAtTime = Time.Now + delay;

		foreach ( var pawn in Game.ActiveScene.GetAllComponents<PlayerPawn>() )
		{
			if ( pawn == null || !pawn.IsValid() ) continue;
			pawn.OnMatchCompleted( wonMatch: false );
		}

		BroadcastChat( "SYSTEM", $"Draw! {MatchEndReason}" );
	}

	private void RestartMatch()
	{
		_matchStartTime = Time.Now;
		_restartAtTime = -1f;
		MatchEnded = false;
		MatchWasDraw = false;
		MatchEndReason = "";
		WinnerName = "";

		foreach ( var pawn in Game.ActiveScene.GetAllComponents<PlayerPawn>() )
		{
			if ( pawn == null || !pawn.IsValid() ) continue;

			pawn.Kills = 0;
			pawn.Deaths = 0;
			pawn.Score = 0;

			if ( pawn.IsBot )
			{
				pawn.Respawn();
			}
			else
			{
				pawn.TriggerRespawn( 0.1f );
			}
		}
	}

	private PlayerPawn FindTopScorer()
	{
		PlayerPawn best = null;
		foreach ( var pawn in Game.ActiveScene.GetAllComponents<PlayerPawn>() )
		{
			if ( pawn == null || !pawn.IsValid() ) continue;
			if ( best == null || pawn.Score > best.Score )
				best = pawn;
		}

		return best;
	}

	private float GetConfiguredMatchLengthSeconds()
	{
		var minutes = Math.Max( 0, GameSettings.MatchTimeLimitMinutes );
		return minutes * 60f;
	}

	// -------------------------------------------------------------------------
	// Spawn points
	// -------------------------------------------------------------------------

	/// <summary>Radius around each spawn point within which ships are randomly scattered.</summary>
	public static float SpawnScatterRadius { get; set; } = 20f;

	public static Transform GetRandomSpawnpoint()
	{
		var spawnpoints = Game.ActiveScene.GetAllComponents<SpawnPoint>().ToList();

		if ( spawnpoints.Count == 0 )
		{
			Log.Warning( "[PilotGame] No SpawnPoints found — spawning at default position." );
			return new Transform( new Vector3( 0, 0, 500f ) );
		}

		var sp = spawnpoints.OrderBy( _ => Guid.NewGuid() ).First().Transform.World;

		// Scatter within a flat radius so ships don't occupy the same point
		var angle  = Game.Random.Float( 0f, 360f ).DegreeToRadian();
		var radius = Game.Random.Float( 0f, SpawnScatterRadius );
		var offset = new Vector3( MathF.Cos( angle ) * radius, MathF.Sin( angle ) * radius, 0f );

		return new Transform( sp.Position + offset, sp.Rotation );
	}
}

/// <summary>Simple event helper for chat messages.</summary>
public static class ChatEvent
{
	public static Action<string, string> OnChat;
	public static void Run( string name, string msg ) => OnChat?.Invoke( name, msg );
}

/// <summary>Simple event bridge so the kill feed UI can receive kill notifications without a direct type reference.</summary>
public static class KillFeedEvent
{
	public static Action<string, string, string> OnKill;
	public static void Run( string killer, string victim, string method ) => OnKill?.Invoke( killer, victim, method );
}