HorizonGame.cs

Main game component for Horizon RP. Creates the lobby, handles connections and spawns players, runs the payday and hunger clock, applies admin orders from the web panel, and handles server shutdown/restart and profile saving.

NetworkingFile AccessHttp Calls
using Sandbox.Network;

namespace HorizonRP;

/// <summary>
/// Le chef d'orchestre du serveur : il crée le lobby, fait apparaître les
/// joueurs, tient l'horloge (salaire, faim) et applique les règles du RP.
///
/// Tout ce qui décide tourne sur l'hôte (le serveur dédié). Les clients ne
/// voient que les valeurs synchronisées et envoient des demandes.
/// </summary>
[Title( "Horizon RP – Jeu" )]
[Icon( "location_city" )]
public sealed partial class HorizonGame : Component, Component.INetworkListener, IChatEvent
{
	/// <summary>
	/// Le moteur annonce chaque arrivée et chaque départ dans le chat, en anglais
	/// (« 👋 Azunis has joined the game ») : on les retire. Le jeu a ses propres
	/// notifications, et le pseudo Steam n'a pas à s'afficher en ville.
	/// </summary>
	public void OnChatMessage( ChatMessageEvent e )
	{
		if ( e.Sender is null && e.Message is { } m && m.StartsWith( "👋" ) && (m.EndsWith( "has joined the game" ) || m.EndsWith( "left the game" )) )
			e.Suppress = true;
	}

	public static HorizonGame Current { get; private set; }

	[Property] public GameObject PlayerPrefab { get; set; }
	[Property] public string ServerName { get; set; } = "Horizon RP";

	/// <summary>Secondes entre deux salaires.</summary>
	[Property] public int PaydayInterval { get; set; } = 300;

	/// <summary>Points de faim perdus par minute : 5 = l'estomac vide en 20 min.</summary>
	[Property] public float HungerPerMinute { get; set; } = 5f;

	/// <summary>Points de vie perdus par minute quand la faim est à zéro.</summary>
	[Property] public float StarvingDamagePerMinute { get; set; } = 10f;

	/// <summary>Secondes entre deux échanges avec l'API web.</summary>
	[Property] public float SyncInterval { get; set; } = 15f;

	[Sync( SyncFlags.FromHost )] public int SecondsToPayday { get; set; }

	/// <summary>SteamID bannis, rafraîchis à chaque échange avec l'API.</summary>
	readonly HashSet<ulong> bans = new();

	/// <summary>Un bannissement posé depuis le menu en jeu, déjà écrit en base : on l'applique tout de suite.</summary>
	public void BanNow( ulong steamId, string reason )
	{
		bans.Add( steamId );
		Connection.All.FirstOrDefault( x => x.SteamId == steamId )?.Kick( reason );
	}
	readonly List<Backend.LogEntry> pendingLogs = new();

	RealTimeSince sinceTick;
	RealTimeSince sinceSync;
	RealTimeSince sinceSave;
	bool syncing;

	/// <summary>L'état des portes tel qu'envoyé la dernière fois : on ne renvoie que s'il change.</summary>
	string lastDoorsJson;
	string lastVehiclesJson;

	/// <summary>Arrêt ou redémarrage en cours : « redemarrer », « arreter », ou null.</summary>
	[Sync( SyncFlags.FromHost )] public string ShutdownMode { get; set; }
	[Sync( SyncFlags.FromHost )] public int ShutdownSeconds { get; set; }
	bool shuttingDown;

	/// <summary>
	/// Le fichier que lancer.bat regarde quand le serveur s'arrête : présent, il ne
	/// relance pas (arrêt) ; absent, il relance (redémarrage, ou plantage).
	/// </summary>
	const string StopFlagFile = "arret.txt";

	protected override void OnAwake()
	{
		Current = this;
		SecondsToPayday = ServerSettings.PaydayInterval;
	}

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

	protected override async Task OnLoad()
	{
		if ( Scene.IsEditor )
			return;

		// Sur le serveur dédié, rien n'existe tant que le jeu ne crée pas son
		// lobby (« Not Connected » côté joueurs).
		if ( !Networking.IsActive )
		{
			LoadingScreen.Title = "Ouverture d'Horizon RP";
			await Task.DelayRealtimeSeconds( 0.1f );
			Networking.CreateLobby( new LobbyConfig { Name = ServerName } );
		}
	}

	// Connexions

	public bool AcceptConnection( Connection channel, ref string reason )
	{
		if ( bans.Contains( channel.SteamId ) )
		{
			reason = "Vous êtes banni d'Horizon RP.";
			return false;
		}

		// Un serveur dédié sans accès à l'API (sans api_serveur.json, ou que l'API n'a jamais
		// accepté) n'est pas un serveur Horizon RP : le jeu publié peut être lancé par n'importe
		// quel serveur dédié, il ne doit accueillir personne (Julien : pas de serveurs à soi).
		if ( Application.IsDedicatedServer && !Backend.IsOnline )
		{
			reason = "Serveur non officiel : Horizon RP ne se joue que sur ses serveurs.";
			return false;
		}

		// Liste blanche (Réglages, « Accès au serveur ») : tant que l'API n'a pas répondu une
		// première fois, on ne sait pas qui est autorisé — on refuse plutôt que d'ouvrir.
		if ( Backend.IsOnline && !ServerSettings.Received )
		{
			reason = "Horizon RP démarre : réessayez dans quelques secondes.";
			return false;
		}
		if ( ServerSettings.WhitelistOn && !ServerSettings.IsWhitelisted( channel.SteamId ) )
		{
			reason = "Horizon RP est en accès restreint (liste blanche) : demandez au staff de vous ajouter.";
			AddLog( "refus_liste_blanche", channel.SteamId, $"Connexion refusée (liste blanche) : {channel.DisplayName}" );
			return false;
		}

		return true;
	}

	public void OnActive( Connection channel )
	{
		if ( !PlayerPrefab.IsValid() )
		{
			Log.Error( "[Horizon] PlayerPrefab n'est pas renseigné sur HorizonGame" );
			return;
		}

		var go = PlayerPrefab.Clone( FindSpawn(), name: $"Joueur - {channel.DisplayName}" );
		var player = go.GetComponent<RoleplayPlayer>();

		ulong steamId = channel.SteamId;
		player.SteamId = steamId;
		// Jamais le pseudo Steam : vide jusqu'au profil. L'échange avec l'API l'écrivait
		// sinon en base par-dessus le vrai nom tant que le profil n'était pas chargé.
		player.RpName = "";
		player.JobId = Jobs.DefaultId;
		player.IsStaff = Admins.IsAdmin( steamId );

		go.NetworkSpawn( channel );

		Log.Info( $"[Horizon] {channel.DisplayName} ({steamId}) est arrivé{(player.IsStaff ? " – staff" : "")}" );
		AddLog( "connexion", steamId, channel.DisplayName );

		_ = LoadProfileAsync( player, steamId, channel.DisplayName );
	}

	public void OnDisconnected( Connection channel )
	{
		ulong steamId = channel.SteamId;
		var player = RoleplayPlayer.All.FirstOrDefault( x => x.SteamId == steamId );

		AddLog( "deconnexion", steamId, channel.DisplayName );
		AdminActions.ReleaseAllOf( steamId );

		if ( player.IsValid() )
		{
			// Ses props posés retournent dans ses poches, son arme dans sa case, avant la sauvegarde.
			PropSystem.Current?.ReturnToOwner( player );
			WeaponSystem.Current?.Holster( player );
			// Fin de service (armes de service reprises, véhicule rendu), peine de prison sauvegardée.
			PoliceService.Current?.OnPlayerLeaving( player );
			_ = SaveAsync( new[] { player } );
		}
	}

	async Task LoadProfileAsync( RoleplayPlayer player, ulong steamId, string displayName )
	{
		PlayerProfile profile;

		if ( Backend.IsOnline )
		{
			// Une requête perdue ne doit pas laisser le joueur sur l'écran de chargement : au
			// premier essai sur le serveur publié (29/10/2026), un seul échec réseau à l'arrivée
			// l'y a laissé indéfiniment. On réessaie (1, 2 puis 4 s) tant qu'il n'y a pas de réponse.
			var response = await Backend.LoadProfile( steamId, displayName );
			for ( var attempt = 0; response is null && attempt < 3 && player.IsValid(); attempt++ )
			{
				await GameTask.DelaySeconds( 1 << attempt );
				Log.Info( $"[Horizon] Profil de {steamId} : nouvel essai ({attempt + 2}/4)" );
				response = await Backend.LoadProfile( steamId, displayName );
			}

			if ( response is null )
			{
				// L'API ne répond pas : mieux vaut le dire que laisser l'écran de chargement tourner.
				Log.Warning( $"[Horizon] Profil de {steamId} : l'API ne répond pas, joueur déconnecté" );
				Connection.All.FirstOrDefault( x => x.SteamId == steamId )?.Kick( "Profil indisponible pour le moment (serveur de données injoignable). Réessayez dans une minute." );
				return;
			}

			if ( response.Banned )
			{
				// Banni depuis le panneau entre deux échanges : on le rattrape ici.
				bans.Add( steamId );
				Connection.All.FirstOrDefault( x => x.SteamId == steamId )?.Kick( response.BanReason ?? "Banni" );
				return;
			}

			profile = response?.Profile;

			if ( profile is null )
			{
				// Sans profil, on ne sauvegardera rien pour ce joueur : écrire ses
				// valeurs par défaut effacerait son vrai solde à la reconnexion.
				Log.Warning( $"[Horizon] Profil de {steamId} indisponible, la session ne sera pas sauvegardée" );
				player.Notify( "Votre profil n'a pas pu être chargé : cette session ne sera pas sauvegardée.", "erreur" );
				return;
			}
		}
		else
		{
			profile = Persistence.Load( steamId, displayName );
		}

		if ( !player.IsValid() )
			return;

		player.ApplyProfile( profile );
		player.Notify( $"Bienvenue sur {ServerName}, {player.RpName} !", "info" );

		// Grade de police, armes de service oubliées, peine de prison en cours (Police/).
		if ( PoliceService.Current is { } police )
			await police.OnProfileLoaded( player );
		if ( !player.IsValid() )
			return;

		if ( VehicleManager.Current is { } vehicles )
			await vehicles.LoadGarage( player );

		if ( PhoneSystem.Current is { } phone && player.IsValid() )
			await phone.LoadFor( player );
	}

	async Task SaveAsync( IEnumerable<RoleplayPlayer> players )
	{
		var profiles = players.Where( x => x.IsValid() && x.ProfileLoaded ).Select( x => x.ToProfile() ).ToList();
		if ( profiles.Count == 0 )
			return;

		if ( Backend.IsOnline )
		{
			await Backend.SaveProfiles( profiles );
			return;
		}

		foreach ( var profile in profiles )
			Persistence.Save( profile );
	}

	/// <summary>Un point d'apparition au hasard (sortie de la création de personnage, réapparition).</summary>
	public Vector3 SpawnPosition() => FindSpawn().Position;

	Transform FindSpawn()
	{
		var spawns = Scene.GetAllComponents<SpawnPoint>().ToArray();
		if ( spawns.Length > 0 )
			return Random.Shared.FromArray( spawns ).WorldTransform.WithScale( 1 );

		// Truenorth encore en construction (le premier arrivé, ou l'hôte dans l'éditeur) : ses
		// apparitions se lisent dans ses données, sans attendre ses SpawnPoint.
		if ( Scene.GetAllComponents<TruenorthMap>().FirstOrDefault()?.Spawns().ToArray() is { Length: > 0 } points )
		{
			var point = Random.Shared.FromArray( points );
			return point.WithPosition( point.Position + Vector3.Up * 4 );
		}

		return WorldTransform.WithScale( 1 );
	}

	/// <summary>
	/// Le staff suit staff_serveur.json sans reconnexion (Admins le relit toutes les 30 s). Le
	/// statut n'était posé qu'à l'arrivée : un membre ajouté pendant qu'il était en ville n'avait
	/// pas le menu (Pepsy, 31/10/2026) avant de se reconnecter.
	/// </summary>
	void TickStaff()
	{
		foreach ( var player in RoleplayPlayer.All )
		{
			var staff = Admins.IsAdmin( player.SteamId );
			if ( staff == player.IsStaff )
				continue;
			player.IsStaff = staff;
			player.Notify( staff ? "Vous êtes maintenant membre du staff (menu Insert)." : "Vous ne faites plus partie du staff.", "info" );
			AddLog( "staff", player.SteamId, staff ? "Ajouté au staff (staff_serveur.json)" : "Retiré du staff (staff_serveur.json)" );
		}
	}

	// Horloge du serveur

	protected override void OnFixedUpdate()
	{
		// Sur toutes les machines : les props mobiles de la carte passent en réseau.
		MapProps.Update( Scene );

		if ( !Networking.IsHost || Scene.IsEditor )
			return;

		if ( sinceTick >= 1f )
		{
			sinceTick = 0;
			TickSecond();
		}

		if ( sinceSync >= SyncInterval && !syncing )
		{
			sinceSync = 0;
			_ = SyncAsync();
		}

		if ( !waitingOrders && Backend.IsOnline )
			_ = WaitOrdersAsync();

		if ( sinceSave >= 60f )
		{
			sinceSave = 0;
			_ = SaveAsync( RoleplayPlayer.All.ToList() );
		}
	}

	void TickSecond()
	{
		TickShutdown();
		TickDowned();
		TickStaff();
		Atm.Tick( Scene );
		TickMapLocks();
		// La police : effectifs (toutes les 30 s), service, escortes, prison.
		PoliceService.Current?.TickSecond();

		SecondsToPayday--;
		if ( SecondsToPayday <= 0 )
		{
			SecondsToPayday = ServerSettings.PaydayInterval;
			Payday();
		}

		// Pas de faim pendant la création du personnage : on n'a pas encore joué.
		foreach ( var player in RoleplayPlayer.All.Where( x => x.ProfileLoaded && !x.NeedsCharacter ) )
		{
			// Mode dieu : la faim ne baisse pas.
			if ( player.GodMode )
				continue;
			player.Hunger = MathF.Max( 0, player.Hunger - ServerSettings.HungerPerMinute / 60f );

			if ( player.Hunger <= 0 && !player.Downed )
				player.Health = MathF.Max( 0, player.Health - ServerSettings.StarvingDamagePerMinute / 60f );

			// Le ventre vide : inconscient, comme toute autre mort (un EMS peut encore sauver).
			if ( player.Health <= 0 && !player.Downed )
				Down( player, "faim" );
		}
	}

	void Payday()
	{
		foreach ( var player in RoleplayPlayer.All )
		{
			// Un policier touche la paie de son grade en service, rien hors service (Police/).
			var salary = PoliceService.SalaryOf( player );
			if ( salary <= 0 || !player.ProfileLoaded || player.NeedsCharacter )
				continue;

			player.Wallet += salary;
			player.Notify( $"Jour de paie : +{Format.Money( salary )} ({PoliceService.PayLabel( player )})", "argent" );
		}

		_ = SaveAsync( RoleplayPlayer.All.ToList() );
	}

	public void Respawn( RoleplayPlayer player, string reason, string kind = "erreur" )
	{
		player.Health = 100;
		player.Hunger = 100;
		player.Held = false;
		WeaponSystem.Current?.Holster( player );
		player.TeleportTo( FindSpawn().Position );
		player.Notify( reason, kind );
	}

	// Arrêt et redémarrage du serveur

	/// <summary>Lance le compte à rebours. Rend null, ou la raison du refus.</summary>
	public string ScheduleShutdown( string mode, int seconds, string by )
	{
		if ( mode is not ("redemarrer" or "arreter") )
			return "Action inconnue";
		if ( shuttingDown )
			return "Le serveur est déjà en train de s'arrêter";

		ShutdownMode = mode;
		ShutdownSeconds = Math.Clamp( seconds, 0, 1800 );
		AnnounceShutdown();
		AddLog( "serveur", 0, $"{(mode == "redemarrer" ? "Redémarrage" : "Arrêt")} dans {ShutdownSeconds} s, demandé par {by}" );
		Log.Info( $"[Horizon] {mode} dans {ShutdownSeconds} s ({by})" );
		return null;
	}

	public string CancelShutdown( string by )
	{
		if ( ShutdownMode is null || shuttingDown )
			return "Aucun arrêt à annuler";

		ShutdownMode = null;
		Announce( "Le redémarrage du serveur est annulé." );
		AddLog( "serveur", 0, $"Arrêt annulé par {by}" );
		return null;
	}

	/// <summary>Les paliers où l'on prévient tout le monde, en secondes.</summary>
	static readonly int[] ShutdownWarnings = { 1800, 1200, 900, 600, 300, 180, 120, 60, 30, 10, 5, 4, 3, 2, 1 };

	void TickShutdown()
	{
		if ( ShutdownMode is null || shuttingDown )
			return;

		if ( ShutdownSeconds <= 0 )
		{
			_ = ShutdownAsync();
			return;
		}

		ShutdownSeconds--;
		if ( ShutdownWarnings.Contains( ShutdownSeconds ) )
			AnnounceShutdown();
	}

	void AnnounceShutdown()
	{
		var what = ShutdownMode == "redemarrer" ? "Redémarrage" : "Fermeture";
		var s = ShutdownSeconds;
		var when = s <= 0 ? "maintenant" : s < 60 ? $"dans {s} s" : s % 60 == 0 ? $"dans {s / 60} min" : $"dans {s / 60} min {s % 60:00}";
		Announce( $"{what} du serveur {when}. Votre progression est sauvegardée." );
	}

	async Task ShutdownAsync()
	{
		shuttingDown = true;
		var restart = ShutdownMode == "redemarrer";

		// Tout le monde est sauvegardé (props posés rendus aux poches), puis un
		// dernier échange vide le journal.
		foreach ( var player in RoleplayPlayer.All.ToList() )
		{
			PropSystem.Current?.ReturnToOwner( player );
			WeaponSystem.Current?.Holster( player );
		}
		foreach ( var player in RoleplayPlayer.All.ToList() )
		{
			PoliceService.Current?.EndService( player, "arrêt du serveur", notify: false );
			PoliceService.Current?.EndEmsService( player, "arrêt du serveur", notify: false );
		}
		await SaveAsync( RoleplayPlayer.All.ToList() );
		if ( PoliceService.Current is { } police )
			await police.SaveAllJail();
		AddLog( "serveur", 0, restart ? "Redémarrage du serveur" : "Arrêt du serveur" );
		if ( !syncing )
			await SyncAsync();

		foreach ( var connection in Connection.All.Where( x => x != Connection.Local ) )
			connection.Kick( restart ? "Le serveur redémarre, reconnectez-vous dans une minute." : "Le serveur est fermé pour le moment." );

		if ( restart )
			FileSystem.Data.DeleteFile( StopFlagFile );
		else
			FileSystem.Data.WriteAllText( StopFlagFile, $"Arrêt demandé le {DateTime.Now:dd/MM/yyyy HH:mm}" );

		Log.Info( restart ? "[Horizon] Redémarrage : lancer.bat relance le serveur" : $"[Horizon] Arrêt : lancer.bat ne relancera pas ({FileSystem.Data.GetFullPath( StopFlagFile )})" );
		await Task.DelayRealtimeSeconds( 1 );
		Game.Close();
	}

	// Règles appliquées aux demandes des joueurs (toujours sur l'hôte)

	public void TryChangeJob( RoleplayPlayer player, string jobId )
	{
		if ( !Jobs.Exists( jobId ) || player.JobId == jobId )
			return;

		var job = Jobs.Get( jobId );

		if ( job.AdminOnly && !player.IsStaff )
		{
			player.Notify( $"Le métier {job.Name} est réservé au staff.", "erreur" );
			return;
		}

		// Policier : liste blanche des effectifs de l'UCPD (panneau, page Police), jamais en prison.
		if ( jobId == PoliceRules.JobId && PoliceService.JobRefusal( player ) is { } refusal )
		{
			player.Notify( refusal, "erreur" );
			return;
		}

		// EMS : liste blanche des effectifs de l'UCFD (panneau, page EMS), jamais en prison.
		if ( jobId == EmsRules.JobId && PoliceService.EmsJobRefusal( player ) is { } emsRefusal )
		{
			player.Notify( emsRefusal, "erreur" );
			return;
		}

		if ( job.MaxPlayers > 0 && RoleplayPlayer.All.Count( x => x.JobId == jobId ) >= job.MaxPlayers )
		{
			player.Notify( $"Plus de place comme {job.Name} ({job.MaxPlayers} max).", "erreur" );
			return;
		}

		player.JobId = jobId;
		player.Notify( $"Vous êtes maintenant {job.Name}.", "info" );
		AddLog( "metier", player.SteamId, job.Id );
	}

	public void TryBuy( RoleplayPlayer player, string itemId )
	{
		var item = Shop.Get( itemId );
		if ( item is null || !player.ProfileLoaded )
			return;

		if ( player.Wallet < item.Price )
		{
			player.Notify( $"Il vous manque {Format.Money( item.Price - player.Wallet )} pour {item.Name}.", "erreur" );
			return;
		}

		// L'achat va dans l'inventaire : on paie seulement s'il y a la place.
		if ( player.AddItem( item, 1 ) == 0 )
		{
			player.Notify( "Inventaire plein ou trop lourd.", "erreur" );
			return;
		}

		player.Wallet -= item.Price;
		player.Notify( $"{item.Name} : -{Format.Money( item.Price )} (dans l'inventaire, touche I)", "argent" );
	}

	/// <summary>Sauvegarde immédiate d'un joueur (création de personnage, échange important).</summary>
	public void SaveNow( RoleplayPlayer player ) => _ = SaveAsync( new[] { player } );

	/// <summary>
	/// Dépôt ou retrait d'espèces : seulement à un distributeur HZ BANK (Atm, 30/10/2026 ; avant,
	/// le menu F4 le permettait partout).
	/// </summary>
	public void TryTransfer( RoleplayPlayer player, int amount, bool toBank, bool notify = true )
	{
		if ( amount <= 0 || !player.ProfileLoaded )
			return;
		if ( !Atm.IsNear( player ) )
		{
			player.Notify( "Dépôts et retraits : à un distributeur HZ Bank.", "erreur" );
			return;
		}

		var available = toBank ? player.Wallet : player.Bank;
		if ( amount > available )
		{
			player.Notify( "Montant supérieur à ce que vous avez.", "erreur" );
			return;
		}

		if ( toBank )
		{
			player.Wallet -= amount;
			Bank.Move( player, amount, "depot", "Dépôt d'espèces" );
		}
		else
		{
			Bank.Move( player, -amount, "retrait", "Retrait d'espèces" );
			player.Wallet += amount;
		}

		if ( notify )
			player.Notify( toBank ? $"Dépôt de {Format.Money( amount )}" : $"Retrait de {Format.Money( amount )}", "argent" );
	}

	[Rpc.Broadcast]
	public void Announce( string message )
	{
		HudNotifications.Push( message, "annonce" );
	}

	// Échanges avec l'API web

	public void AddLog( string action, ulong steamId, string details )
	{
		if ( !Backend.IsOnline )
			return;

		pendingLogs.Add( new Backend.LogEntry { Action = action, SteamId = steamId, Details = details } );
	}

	async Task SyncAsync()
	{
		if ( !Backend.IsOnline )
			return;

		syncing = true;

		try
		{
			var logs = pendingLogs.ToList();
			pendingLogs.Clear();

			var request = new Backend.SyncRequest
			{
				// La carte affichée par le panneau : Truenorth n'est pas une MapInstance (World/TruenorthMap.cs).
				Map = Scene.GetAllComponents<MapInstance>().FirstOrDefault()?.MapName
					?? (Scene.GetAllComponents<TruenorthMap>().Any() ? "rp_truenorth (Statua)" : null),
				MaxPlayers = Networking.MaxPlayers,
				Logs = logs,
				Players = RoleplayPlayer.All.Select( x => new Backend.SyncPlayer
				{
					SteamId = x.SteamId,
					RpName = x.RpName,
					DisplayName = x.Network.Owner?.DisplayName,
					Job = x.JobId,
					Wallet = x.Wallet,
					Bank = x.Bank,
					ProfileLoaded = x.ProfileLoaded,
					Health = x.Health.CeilToInt(),
					Hunger = x.Hunger.CeilToInt(),
					Frozen = x.Frozen,
					Downed = x.Downed,
				} ).ToList(),
				Shutdown = ShutdownMode is null ? null : new Backend.ShutdownInfo { Mode = ShutdownMode, Seconds = ShutdownSeconds },
			};

			// L'état des portes ne part que s'il a changé depuis le dernier envoi réussi.
			var doors = DoorSystem.Current?.Snapshot();
			var doorsJson = doors is null ? null : Json.Serialize( doors );
			if ( doorsJson != lastDoorsJson )
				request.Doors = doors;

			var vehicles = VehicleManager.Current?.Snapshot();
			var vehiclesJson = vehicles is null ? null : Json.Serialize( vehicles );
			if ( vehiclesJson != lastVehiclesJson )
				request.Vehicles = vehicles;

			var response = await Backend.Sync( request );

			if ( response is null )
			{
				// Les lignes du journal repartiront au prochain échange.
				pendingLogs.InsertRange( 0, logs );
				return;
			}

			if ( request.Doors is not null )
				lastDoorsJson = doorsJson;
			if ( request.Vehicles is not null )
				lastVehiclesJson = vehiclesJson;

			PropSystem.Current?.CheckVersion( response.PropsVersion );
			WeaponSystem.Current?.CheckVersion( response.WeaponsVersion );
			VehicleManager.Current?.CheckVersion( response.VehiclesVersion );
			ServerSettings.Apply( response.Settings );

			if ( response.Bans is not null )
			{
				bans.Clear();
				foreach ( var id in response.Bans )
					bans.Add( id );
			}

			await RunOrders( response.Orders );
		}
		finally
		{
			syncing = false;
		}
	}

	bool waitingOrders;

	/// <summary>
	/// Les ordres du panneau web arrivent par une attente longue toujours ouverte
	/// (serverOrdersWait) : l'API répond dès qu'un ordre est posé, et il s'exécute en
	/// une seconde au lieu d'attendre l'échange régulier. Celui-ci en relève aussi, au
	/// cas où l'attente tombe ; l'API ne donne jamais deux fois le même.
	/// </summary>
	async Task WaitOrdersAsync()
	{
		waitingOrders = true;
		try
		{
			var started = RealTime.Now;
			var response = await Backend.WaitOrders( 20 );
			if ( !this.IsValid() )
				return;

			// API injoignable ou qui refuse (action inconnue) : on ne la relance pas en
			// boucle, on attend un peu. Imunify360 bannit l'IP qui enchaîne les requêtes.
			if ( response?.Ok != true )
			{
				await GameTask.DelaySeconds( 10 );
				return;
			}

			await RunOrders( response.Orders );

			// Une réponse vide arrivée trop vite (attente coupée par l'hébergeur) : pas de rafale.
			if ( (response.Orders?.Count ?? 0) == 0 && RealTime.Now - started < 5f )
				await GameTask.DelaySeconds( 5 );
		}
		finally
		{
			waitingOrders = false;
		}
	}

	async Task RunOrders( List<Backend.Order> orders )
	{
		foreach ( var order in orders ?? new() )
		{
			var (ok, result) = ApplyOrder( order );
			await Backend.OrderDone( order.Id, ok, result );
		}
	}

	(bool ok, string result) ApplyOrder( Backend.Order order )
	{
		if ( order.Type == "annonce" )
		{
			Announce( order.Text );
			return (true, "Annonce diffusée");
		}

		var by = $"le panneau (ordre n°{order.Id})";

		switch ( order.Type )
		{
			case "serveur":
				var refusal = order.Text == "annuler" ? CancelShutdown( by ) : ScheduleShutdown( order.Text, order.Value, by );
				return (refusal is null, refusal ?? (order.Text == "annuler" ? "Arrêt annulé" : $"Compte à rebours lancé ({order.Value} s)"));

			case "porte":
				return ApplyDoorConfig( order.Text );

			case "porte_liberer":
			{
				if ( DoorSystem.Current is not { } doorSystem )
					return (false, "Système de portes absent");
				var owner = doorSystem.ReleaseByStaff( order.Text, out var found );
				return (found, !found ? "Porte introuvable sur la carte" : owner is null ? "La porte était libre" : $"Porte reprise à {owner}");
			}

			case "porte_verrou":
				return DoorSystem.Current?.LockByStaff( order.Text, order.Value != 0 ) == true
					? (true, order.Value != 0 ? "Porte verrouillée" : "Porte déverrouillée")
					: (false, "Porte introuvable sur la carte");

			case "police_sync":
				if ( PoliceService.Current is not { } police )
					return (false, "Système de police absent");
				police.RequestSync();
				return (true, "Effectifs et code pénal relus");

			case "ems_sync":
				if ( PoliceService.Current is not { } services )
					return (false, "Système des secours absent");
				services.RequestEmsSync();
				return (true, "Effectifs des secours relus");
		}

		var player = RoleplayPlayer.All.FirstOrDefault( x => x.SteamId == order.SteamId );
		var connection = Connection.All.FirstOrDefault( x => x.SteamId == order.SteamId );

		if ( order.Type == "ban" )
		{
			// Le bannissement est déjà en base : ici on ne fait que l'appliquer.
			bans.Add( order.SteamId );
			connection?.Kick( order.Text ?? "Banni" );
			return (true, connection is null ? "Joueur déjà hors ligne" : "Joueur expulsé");
		}

		if ( !player.IsValid() )
			return (false, "Joueur hors ligne");

		switch ( order.Type )
		{
			case "expulser":
				connection?.Kick( order.Text ?? "Expulsé par le staff" );
				return (true, "Joueur expulsé");

			case "portefeuille":
				player.Wallet = Math.Max( 0, order.Value );
				player.Notify( $"Le staff a fixé votre portefeuille à {Format.Money( player.Wallet )}.", "argent" );
				_ = SaveAsync( new[] { player } );
				return (true, $"Portefeuille : {Format.Money( player.Wallet )}");

			case "banque":
				var before = player.Bank;
				player.Bank = Math.Max( 0, order.Value );
				Bank.Record( player, player.Bank - before, "staff", "Régularisation HZ BANK" );
				player.Notify( $"Le staff a fixé votre compte en banque à {Format.Money( player.Bank )}.", "argent" );
				_ = SaveAsync( new[] { player } );
				return (true, $"Banque : {Format.Money( player.Bank )}");

			case "liberer":
				return PoliceService.Current?.ReleaseFromJail( player, by ) ?? (false, "Système de police absent");

			case "metier":
				if ( !Jobs.Exists( order.Text ) )
					return (false, $"Métier inconnu : {order.Text}");
				player.JobId = order.Text;
				player.Notify( $"Le staff vous a nommé {player.Job.Name}.", "info" );
				return (true, $"Métier : {player.Job.Name}");

			case "soigner":
				player.Health = 100;
				player.Hunger = 100;
				player.Notify( "Le staff vous a soigné.", "info" );
				return (true, "Soigné");

			case "message":
				player.Notify( order.Text, "annonce" );
				return (true, "Message envoyé");

			case "gel":
			case "degel":
				SetFrozen( player, order.Type == "gel" );
				return (true, order.Type == "gel" ? "Joueur gelé" : "Joueur dégelé");

			case "reapparition":
				Respawn( player, "Le staff vous a fait réapparaître.", "annonce" );
				return (true, "Réapparu au point d'apparition");

			case "tuer":
				if ( player.Downed )
					return (false, "Déjà inconscient");
				Down( player, "tué par le staff (panneau web)" );
				return (true, "Joueur inconscient");

			case "reanimer":
				if ( !player.Downed )
					return (false, "Le joueur n'est pas inconscient");
				Revive( player, null );
				return (true, "Joueur réanimé");

			case "tp":
			{
				var destination = RoleplayPlayer.All.FirstOrDefault( x => x.SteamId.ToString() == order.Text );
				if ( !destination.IsValid() )
					return (false, "Joueur de destination hors ligne");
				player.TeleportTo( destination.WorldPosition + destination.WorldRotation.Forward.WithZ( 0 ).Normal * 60 );
				player.Notify( "Le staff vous a téléporté.", "annonce" );
				return (true, $"Téléporté sur {destination.RpName}");
			}

			case "objet":
				return GiveItem( player, order.Text, order.Value );

			case "vehicule":
			{
				// « modèle|#couleur », vérifié par l'API.
				var parts = (order.Text ?? "").Split( '|' );
				return VehicleManager.Current?.StaffSpawn( player, parts[0], parts.Length > 1 ? parts[1] : null, by ) ?? (false, "Gestion des véhicules absente");
			}

			case "tp_vehicule":
			{
				// Le panneau web : un joueur téléporté à côté d'un véhicule (texte = plaque).
				var vehicle = Vehicle.All.FirstOrDefault( x => x.Plate == order.Text );
				if ( vehicle is null )
					return (false, $"Véhicule {order.Text} introuvable en ville");
				var side = vehicle.WorldRotation.Left * ((vehicle.Rig?.BodyBounds.Size.y ?? 80) * 0.5f + 40);
				player.TeleportTo( vehicle.WorldPosition + side + Vector3.Up * 10 );
				player.Notify( "Le staff vous a téléporté.", "annonce" );
				return (true, $"Téléporté à côté de {vehicle.Plate}");
			}

			case "vehicule_coffre":
			{
				// « plaque|emplacement » : le panneau web supprime un objet du coffre.
				var parts = (order.Text ?? "").Split( '|' );
				var vehicle = Vehicle.All.FirstOrDefault( x => x.Plate == parts[0] );
				if ( vehicle is null || parts.Length < 2 || !int.TryParse( parts[1], out var trunkSlot ) )
					return (false, $"Véhicule {parts[0]} introuvable en ville");
				return vehicle.StaffTrunk( trunkSlot, "jeter", by );
			}

			case "vehicule_gestion":
			{
				// « plaque|action », depuis la page Véhicules du panneau ; le joueur est le propriétaire.
				var parts = (order.Text ?? "").Split( '|' );
				var vehicle = Vehicle.All.FirstOrDefault( x => x.Plate == parts[0] );
				if ( vehicle is null || parts.Length < 2 )
					return (false, $"Véhicule {parts[0]} introuvable en ville");
				return VehicleManager.Current?.StaffAction( vehicle, parts[1], by ) ?? (false, "Gestion des véhicules absente");
			}

			case "retirer":
				return TakeItem( player, order.Text, order.Value );

			case "vider":
				return ClearInventory( player );

			case "nom":
			{
				var parts = (order.Text ?? "").Split( '|' );
				return parts.Length == 2 ? Rename( player, parts[0], parts[1] ) : (false, "Nom illisible");
			}

			case "capture":
				return RequestCapture( player, order.Text ?? "le panneau" );
		}

		return (false, $"Ordre inconnu : {order.Type}");
	}

	// Actions du staff, communes au panneau web (ordres) et au menu F6

	(bool, string) ApplyDoorConfig( string text )
	{
		// « clé|prix|achetable|métiers|titre », écrit par l'API ; le réglage est déjà en base.
		var parts = (text ?? "").Split( '|', 5 );
		if ( parts.Length != 5 || !int.TryParse( parts[1], out var price ) )
			return (false, "Réglage illisible");

		var error = DoorSystem.Current?.ApplyStaffConfig( new Backend.DoorConfig
		{
			Key = parts[0],
			Price = price,
			Buyable = parts[2] == "1",
			Jobs = parts[3],
			Title = parts[4],
		} ) ?? "Système de portes absent";
		return (error is null, error ?? "Réglage appliqué en jeu");
	}

	public void SetFrozen( RoleplayPlayer player, bool frozen )
	{
		player.Frozen = frozen;
		player.Notify( frozen ? "Le staff vous a gelé." : "Vous pouvez de nouveau bouger.", "annonce" );
	}

	public (bool, string) GiveItem( RoleplayPlayer player, string itemId, int count )
	{
		var item = Items.Get( itemId );
		if ( item is null )
			return (false, $"Objet inconnu : {itemId}");
		if ( !player.ProfileLoaded )
			return (false, "Profil pas encore chargé");

		count = Math.Clamp( count, 1, 100 );
		// Une arme arrive chargeur plein (Julien, 30/10/2026) : chacune son numéro et ses balles.
		var given = 0;
		if ( WeaponSystem.Current?.Get( WeaponItems.WeaponOf( itemId ) ) is { } loaded )
		{
			for ( var i = 0; i < count; i++ )
				given += player.AddItem( item, 1, new WeaponData { Serial = WeaponData.NewSerial(), Ammo = loaded.ClipSize }.Write() );
		}
		else
			given = player.AddItem( item, count );
		if ( given == 0 )
			return (false, "Inventaire plein ou trop lourd");

		// Une arme arrive avec trois chargeurs de ses munitions (si la place le permet).
		var extra = "";
		if ( WeaponSystem.Current?.Get( WeaponItems.WeaponOf( itemId ) ) is { } weapon && Items.Get( weapon.Ammo ) is { } ammo )
		{
			var rounds = player.AddItem( ammo, Math.Clamp( weapon.ClipSize * 3 * given, 1, 300 ) );
			if ( rounds > 0 )
				extra = $" et {rounds} × {ammo.Name}";
		}

		player.Notify( $"Le staff vous a donné {given} × {item.Name}{extra}.", "info" );
		SaveNow( player );
		return (true, given < count ? $"{given} × {item.Name} donnés sur {count} (inventaire plein)" : $"{given} × {item.Name} donnés");
	}

	/// <summary>Le staff vide les poches d'un joueur (arme rangée d'abord), journalisé.</summary>
	public (bool, string) ClearInventory( RoleplayPlayer player )
	{
		var count = player.Inventory.Sum( x => x.Count );
		if ( count == 0 )
			return (false, "Ses poches sont déjà vides");
		WeaponSystem.Current?.Holster( player );
		player.HeldKey = null;
		foreach ( var slot in player.Inventory.ToList() )
			player.RemoveFrom( slot, slot.Count );
		player.Notify( "Le staff a vidé vos poches.", "annonce" );
		SaveNow( player );
		return (true, $"inventaire vidé ({count} objet(s))");
	}

	public (bool, string) TakeItem( RoleplayPlayer player, string itemId, int count )
	{
		var item = Items.Get( itemId );
		if ( item is null )
			return (false, $"Objet inconnu : {itemId}");

		count = Math.Clamp( count, 1, 1000 );
		var left = count;
		foreach ( var slot in player.Inventory.Where( x => x.Item == itemId ).OrderByDescending( x => x.Slot ).ToList() )
		{
			var take = Math.Min( left, slot.Count );
			WeaponSystem.Current?.HolsterIfActive( player, slot );
			player.RemoveFrom( slot, take );
			left -= take;
			if ( left == 0 ) break;
		}

		var taken = count - left;
		if ( taken == 0 )
			return (false, $"Le joueur n'a pas de {item.Name}");

		player.Notify( $"Le staff vous a retiré {taken} × {item.Name}.", "info" );
		SaveNow( player );
		return (true, $"{taken} × {item.Name} retirés");
	}

	public (bool, string) Rename( RoleplayPlayer player, string firstName, string lastName )
	{
		var first = CharacterRules.CleanName( firstName );
		var last = CharacterRules.CleanName( lastName );
		if ( first is null || last is null )
			return (false, "Prénom et nom : 2 à 20 lettres chacun");

		player.RpName = $"{first} {last}";
		player.Notify( $"Le staff vous a renommé {player.RpName}.", "annonce" );
		SaveNow( player );
		return (true, $"Renommé {player.RpName}");
	}

	/// <summary>
	/// Demande un jeton à l'API, puis la capture au client du joueur, qui l'envoie
	/// lui-même : l'image ne transite pas par le serveur de jeu.
	/// </summary>
	public (bool, string) RequestCapture( RoleplayPlayer player, string by )
	{
		if ( !Backend.IsOnline )
			return (false, "Capture impossible sans l'API (partie locale)");

		_ = CaptureAsync( player, by );
		return (true, "Capture demandée au joueur");
	}

	async Task CaptureAsync( RoleplayPlayer player, string by )
	{
		var response = await Backend.RequestCapture( player.SteamId, by );
		if ( response?.Ok != true || string.IsNullOrEmpty( response.Token ) || !player.IsValid() )
		{
			Log.Warning( $"[Horizon] Capture de {player?.SteamId} : pas de jeton ({response?.Error ?? "API injoignable"})" );
			return;
		}

		player.TakeScreenshot( response.Token, Backend.CaptureUploadUrl );
	}
}