Admin/AdminActions.cs

Server-side RPC handlers for the in-game F6 admin menu in a roleplay gamemode. Every entry point checks the caller is staff, then performs city object placement, server settings and whitelist edits, player actions (teleport, money, ban, kick, freeze, revive), log and player-file lookups through the Backend API, door release/lock, and a host-driven physgun.

Networking
namespace HorizonRP;

/// <summary>
/// Ce que le menu d'administration en jeu (F6) demande à l'hôte. Chaque appel
/// vérifie, sur l'hôte, que l'appelant est bien staff (liste de
/// Admins.Server.cs) : le menu caché côté client n'est qu'un confort, pas une
/// protection.
/// </summary>
public static class AdminActions
{
	/// <summary>Le joueur staff qui appelle, ou null s'il ne l'est pas.</summary>
	static RoleplayPlayer CallingStaff()
	{
		var caller = Rpc.Caller;
		if ( caller is null )
			return null;

		var player = RoleplayPlayer.All.FirstOrDefault( x => x.Network.OwnerId == caller.Id );
		if ( player is null || !player.IsStaff )
		{
			Log.Warning( $"[Horizon] Action d'administration refusée à {caller.DisplayName} ({caller.SteamId})" );
			return null;
		}

		return player;
	}

	static string Signature( RoleplayPlayer staff ) => $"{staff.RpName} ({staff.SteamId})";

	/// <summary>Plafond d'un solde, MONEY_MAX d'api.php (colonne INT signée).</summary>
	public const int MoneyMax = 2_000_000_000;

	/// <summary>Un message privé, comme dans le panneau web.</summary>
	public const int MessageMax = 500;

	static void Answer( RoleplayPlayer staff, string error, string success )
	{
		if ( staff.IsValid() )
			staff.Notify( error ?? success, error is null ? "info" : "erreur" );
	}

	// La ville

	[Rpc.Host]
	public static void RequestPlace( string kind, string model, string label, string data, Vector3 position, Rotation rotation, float scale )
	{
		var staff = CallingStaff();
		if ( staff is null || CityManager.Current is null )
			return;

		if ( !CityKinds.All.Any( x => x.Id == kind ) || !ModelSources.LooksValid( model ) )
		{
			staff.Notify( "Sorte d'objet ou modèle refusé.", "erreur" );
			return;
		}

		var angles = rotation.Angles();
		var placement = new Backend.Placement
		{
			Kind = kind,
			Model = model,
			Label = (label ?? "").Trim(),
			Data = (data ?? "").Trim(),
			X = position.x, Y = position.y, Z = position.z,
			Pitch = angles.pitch, Yaw = angles.yaw, Roll = angles.roll,
			Scale = Math.Clamp( scale, 0.05f, 20f ),
		};

		_ = PlaceAsync( staff, placement );
	}

	static async Task PlaceAsync( RoleplayPlayer staff, Backend.Placement placement )
	{
		var error = await CityManager.Current.Place( placement, Signature( staff ) );
		Answer( staff, error, $"{CityKinds.Get( placement.Kind ).Name} posé (n°{placement.Id})." );
	}

	[Rpc.Host]
	public static void RequestMove( long id, Vector3 position, Rotation rotation, float scale )
	{
		var staff = CallingStaff();
		if ( staff is null || CityManager.Current is null )
			return;

		_ = MoveAsync( staff, id, position, rotation, Math.Clamp( scale, 0.05f, 20f ) );
	}

	static async Task MoveAsync( RoleplayPlayer staff, long id, Vector3 position, Rotation rotation, float scale )
	{
		var error = await CityManager.Current.Move( id, position, rotation, scale, Signature( staff ) );
		Answer( staff, error, $"Objet n°{id} déplacé." );
	}

	[Rpc.Host]
	public static void RequestRemove( long id )
	{
		var staff = CallingStaff();
		if ( staff is null || CityManager.Current is null )
			return;

		_ = RemoveAsync( staff, id );
	}

	static async Task RemoveAsync( RoleplayPlayer staff, long id )
	{
		var error = await CityManager.Current.Remove( id, Signature( staff ) );
		Answer( staff, error, $"Objet n°{id} supprimé." );
	}

	// Les réglages du serveur (onglet Réglages)

	/// <summary>Un nombre ou un oui/non (ServerSettings.Meta).</summary>
	[Rpc.Host]
	public static void RequestSettingSave( string key, string value )
	{
		var staff = CallingStaff();
		var meta = ServerSettings.Meta.FirstOrDefault( x => x.Key == key );
		if ( staff is null || meta is null || meta.Type is not ("int" or "float" or "bool") || !float.TryParse( value?.Replace( ',', '.' ), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var n ) )
			return;
		n = Math.Clamp( n, meta.Min, meta.Max );
		if ( meta.Type != "float" )
			n = MathF.Round( n );
		// Une liste blanche vide refuserait tout le monde à la connexion, staff compris.
		if ( key == "liste_blanche_active" && n >= 0.5f && ServerSettings.Whitelist.Count == 0 )
		{
			staff.Notify( "Ajoutez d'abord au moins un joueur (vous-même) : une liste blanche vide refuserait tout le monde, staff compris.", "erreur" );
			return;
		}
		_ = SaveSetting( staff, key, n, n.ToString( System.Globalization.CultureInfo.InvariantCulture ), $"{meta.Label} : {n}" );
	}

	/// <summary>
	/// L'heure de la ville (« HH:MM ») : le décalage qui la donne maintenant, calculé comme
	/// adminWorldTime d'api.php, enregistré dans « heure_decalage ». Toutes les machines suivent
	/// (GameClock), le jour et la nuit avec.
	/// </summary>
	[Rpc.Host]
	public static void RequestWorldTime( string time )
	{
		var staff = CallingStaff();
		if ( staff is null )
			return;
		var parts = (time ?? "").Trim().Split( ':' );
		if ( parts.Length != 2 || !int.TryParse( parts[0], out var h ) || !int.TryParse( parts[1], out var m ) || parts[1].Length != 2 || h is < 0 or > 23 || m is < 0 or > 59 )
		{
			staff.Notify( "Heure invalide : « HH:MM » est attendu.", "erreur" );
			return;
		}
		var target = h * 60 + m;
		// L'horloge sans décalage, à l'instant : le même calcul que GameClock.Minutes.
		var seconds = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() / 1000.0;
		var bare = seconds * 1440.0 / (GameClock.DayMinutes * 60.0) % 1440;
		var offset = (int)Math.Round( (((target - bare) % 1440) + 1440) % 1440 ) % 1440;
		_ = SetWorldTime( staff, offset, $"{h:00}:{m:00}" );
	}

	static async Task SetWorldTime( RoleplayPlayer staff, int offset, string time )
	{
		// Comme le panneau : le réglage est journalisé par l'API, l'heure choisie l'est en plus.
		if ( await SaveSetting( staff, "heure_decalage", offset, offset.ToString( System.Globalization.CultureInfo.InvariantCulture ), $"Heure du monde mise à {time}." ) )
			HorizonGame.Current?.AddLog( "heure", staff.SteamId, $"Heure du monde mise à {time} par {Signature( staff )}" );
	}

	/// <summary>La liste blanche : « ajouter » un SteamID64 (avec un nom pour s'y retrouver) ou le « retirer ».</summary>
	[Rpc.Host]
	public static void RequestWhitelist( string mode, string steamId, string name )
	{
		var staff = CallingStaff();
		if ( staff is null )
			return;
		var id = new string( (steamId ?? "").Where( char.IsDigit ).ToArray() );
		var list = ServerSettings.Whitelist;
		string done;
		if ( mode == "ajouter" )
		{
			// Même contrôle que settingSave d'api.php : 17 chiffres, commence par 7656.
			if ( id.Length != 17 || !id.StartsWith( "7656" ) )
			{
				staff.Notify( "SteamID64 invalide : 17 chiffres, commence par 7656.", "erreur" );
				return;
			}
			if ( list.Count >= 200 )
			{
				staff.Notify( "Deux cents joueurs au plus dans la liste blanche.", "erreur" );
				return;
			}
			name = (name ?? "").Trim();
			if ( string.IsNullOrEmpty( name ) )
				name = RoleplayPlayer.All.FirstOrDefault( x => x.SteamId.ToString() == id )?.RpName ?? "";
			if ( name.Length > 64 )
				name = name[..64];
			list.RemoveAll( x => x.SteamId == id );
			list.Add( new ServerSettings.WhitelistEntry( id, name ) );
			done = $"{(string.IsNullOrEmpty( name ) ? id : name)} ajouté à la liste blanche.";
		}
		else if ( mode == "retirer" )
		{
			if ( list.RemoveAll( x => x.SteamId == id ) == 0 )
				return;
			if ( list.Count == 0 && ServerSettings.WhitelistOn )
			{
				staff.Notify( "C'est le dernier joueur de la liste : désactivez d'abord la liste blanche.", "erreur" );
				return;
			}
			done = $"{id} retiré de la liste blanche.";
		}
		else
			return;

		var value = list.Select( x => new { steamId = x.SteamId, name = x.Name } ).ToList();
		_ = SaveSetting( staff, "liste_blanche", value, Json.Serialize( value ), done );
	}

	/// <summary>
	/// Les points de l'hôpital, là où se tient le staff : « reveil » (le réveil après une
	/// réanimation), « ajouter » (une réapparition après une mort), « retirer:N ».
	/// </summary>
	[Rpc.Host]
	public static void RequestSettingPoint( string mode )
	{
		var staff = CallingStaff();
		if ( staff is null )
			return;
		var yaw = staff.GetComponent<PlayerController>()?.EyeAngles.yaw ?? staff.WorldRotation.Yaw();
		var here = new ServerSettings.Point( staff.WorldPosition.x, staff.WorldPosition.y, staff.WorldPosition.z, yaw );
		if ( mode == "reveil" )
		{
			_ = SaveSetting( staff, "point_reveil", new { x = here.X, y = here.Y, z = here.Z, yaw = here.Yaw }, ServerSettings.PointJson( here ), "Réveil à l'hôpital posé ici." );
			return;
		}
		var points = ServerSettings.RespawnPoints;
		if ( mode == "ajouter" )
		{
			if ( points.Count >= 10 )
			{
				staff.Notify( "Dix points de réapparition au plus.", "erreur" );
				return;
			}
			points.Add( here );
		}
		else if ( mode?.StartsWith( "retirer:" ) == true && int.TryParse( mode[8..], out var i ) && i >= 0 && i < points.Count )
			points.RemoveAt( i );
		else
			return;
		var list = points.Select( p => new { x = p.X, y = p.Y, z = p.Z, yaw = p.Yaw } ).ToList();
		_ = SaveSetting( staff, "points_reapparition", list, ServerSettings.PointsJson( points ), mode == "ajouter" ? "Point de réapparition ajouté ici." : "Point de réapparition retiré." );
	}

	// La garde-robe (onglets Garde-robe et Tenues de service)

	/// <summary>Un vêtement masqué (ou de nouveau proposé) dans la création de personnage.</summary>
	[Rpc.Host]
	public static void RequestClothingHidden( string path, bool hide )
	{
		var staff = CallingStaff();
		var clothing = string.IsNullOrEmpty( path ) ? null : ResourceLibrary.Get<Clothing>( path );
		if ( staff is null || clothing is null )
			return;
		var list = ServerSettings.HiddenClothing.ToList();
		list.RemoveAll( x => string.Equals( x, clothing.ResourcePath, StringComparison.OrdinalIgnoreCase ) );
		if ( hide )
			list.Add( clothing.ResourcePath );
		var name = string.IsNullOrWhiteSpace( clothing.Title ) ? clothing.ResourcePath : clothing.Title;
		_ = SaveSetting( staff, ServerSettings.HiddenClothingKey, list, Json.Serialize( list ),
			hide ? $"{name} : masqué dans la création de personnage." : $"{name} : de nouveau proposé à la création." );
	}

	/// <summary>
	/// La tenue de service d'un grade : ses pièces (chemins .clothing, 16 au plus), ou une liste vide
	/// pour revenir à la tenue d'origine (PoliceUniform.DefaultPiecesOf).
	/// </summary>
	[Rpc.Host]
	public static void RequestUniformSave( string service, int grade, string piecesJson )
	{
		var staff = CallingStaff();
		if ( staff is null || service != PoliceUniform.Service || grade < 0 || grade >= PoliceRules.Grades.Length )
			return;
		List<string> pieces;
		try
		{
			pieces = Json.Deserialize<List<string>>( piecesJson ?? "[]" ) ?? new();
		}
		catch
		{
			return;
		}
		// Seulement des vêtements qui existent : une pièce introuvable ferait échouer toute la tenue.
		pieces = pieces.Select( p => ResourceLibrary.Get<Clothing>( p )?.ResourcePath ).Where( p => p is not null ).Distinct().Take( 16 ).ToList();
		var all = ServerSettings.Uniforms;
		if ( !all.TryGetValue( service, out var grades ) )
			all[service] = grades = new();
		grades[grade] = pieces;
		var value = ServerSettings.UniformsValue( all );
		var gradeName = PoliceRules.GradeOf( grade ).Name;
		_ = SaveSetting( staff, ServerSettings.UniformsKey, value, Json.Serialize( value ),
			pieces.Count == 0 ? $"Tenue de service ({gradeName}) : retour à la tenue d'origine." : $"Tenue de service ({gradeName}) enregistrée : {pieces.Count} pièce(s)." );
	}

	/// <summary>Enregistre un réglage par l'API (ou localement sans elle) ; vrai s'il est pris.</summary>
	static async Task<bool> SaveSetting( RoleplayPlayer staff, string key, object value, string json, string done )
	{
		if ( !Backend.IsOnline )
		{
			ServerSettings.SetLocal( key, json );
			staff.Notify( done + " (partie locale : non enregistré)", "info" );
			return true;
		}
		var res = await Backend.SaveSetting( key, value, $"{staff.RpName} ({staff.SteamId})" );
		if ( res?.Ok != true )
		{
			staff.Notify( res?.Error ?? "API injoignable : réglage non enregistré.", "erreur" );
			return false;
		}
		ServerSettings.SetLocal( key, json );
		staff.Notify( done, "info" );
		return true;
	}

	// Les joueurs

	[Rpc.Host]
	public static void RequestPlayerAction( ulong steamId, string action, int value, string text )
	{
		var staff = CallingStaff();
		var game = HorizonGame.Current;
		if ( staff is null || game is null )
			return;

		var target = RoleplayPlayer.All.FirstOrDefault( x => x.SteamId == steamId );
		if ( target is null )
		{
			staff.Notify( "Joueur introuvable (déconnecté ?).", "erreur" );
			return;
		}

		string done;
		switch ( action )
		{
			case "amener":
				// Devant le staff, à un mètre et demi, pour ne pas apparaître dans lui.
				target.TeleportTo( staff.WorldPosition + staff.WorldRotation.Forward.WithZ( 0 ).Normal * 60 );
				target.Notify( "Un membre du staff vous a fait venir.", "annonce" );
				done = $"{target.RpName} amené à vous.";
				break;

			case "vider":
			{
				var (ok, result) = game.ClearInventory( target );
				if ( !ok )
				{
					staff.Notify( result, "erreur" );
					return;
				}
				done = $"{target.RpName} : {result}.";
				break;
			}

			case "soigner":
				target.Health = 100;
				target.Hunger = 100;
				target.Notify( "Le staff vous a soigné.", "info" );
				done = $"{target.RpName} soigné.";
				break;

			case "dieu":
				// Le mode dieu : soigné, puis plus de dégâts ni de faim jusqu'à sa déconnexion.
				target.GodMode = !target.GodMode;
				if ( target.GodMode )
				{
					target.Health = 100;
					target.Hunger = 100;
				}
				target.Notify( target.GodMode ? "Le staff vous a mis en mode dieu : ni dégâts ni faim." : "Mode dieu retiré : vous êtes de nouveau mortel.", "info" );
				done = target.GodMode ? $"{target.RpName} en mode dieu (jusqu'à sa déconnexion)." : $"{target.RpName} n'est plus en mode dieu.";
				break;

			case "metier":
				if ( !Jobs.Exists( text ) )
				{
					staff.Notify( "Métier inconnu.", "erreur" );
					return;
				}
				target.JobId = text;
				target.Notify( $"Le staff vous a nommé {target.Job.Name}.", "info" );
				done = $"{target.RpName} est maintenant {target.Job.Name}.";
				break;

			case "argent":
			case "banque":
			case "argent_fixer":
			case "banque_fixer":
			{
				// « argent » / « banque » ajoutent (ou retirent) ; « …_fixer » met le montant exact.
				if ( !target.ProfileLoaded )
				{
					staff.Notify( "Profil pas encore chargé, réessayez dans un instant.", "erreur" );
					return;
				}
				var isBank = action.StartsWith( "banque" );
				long before = isBank ? target.Bank : target.Wallet;
				var after = (int)Math.Clamp( action.EndsWith( "_fixer" ) ? value : before + value, 0, MoneyMax );
				var delta = after - before;
				if ( delta == 0 )
				{
					staff.Notify( $"Rien à changer : {Format.Money( after )}.", "info" );
					return;
				}
				var sign = delta > 0 ? "+" : "";
				if ( !isBank )
				{
					target.Wallet = after;
					target.Notify( $"Le staff a modifié votre portefeuille : {sign}{Format.Money( (int)delta )}.", "argent" );
					done = $"Portefeuille de {target.RpName} : {Format.Money( target.Wallet )}.";
				}
				else
				{
					target.Bank = after;
					// L'historique HZ BANK du joueur montre la retouche, comme une opération de la banque.
					Bank.Record( target, (int)delta, "staff", "Régularisation HZ BANK" );
					target.Notify( $"Le staff a modifié votre compte en banque : {sign}{Format.Money( (int)delta )}.", "argent" );
					done = $"Banque de {target.RpName} : {Format.Money( target.Bank )}.";
				}
				break;
			}

			case "message":
				if ( string.IsNullOrWhiteSpace( text ) )
					return;
				// 500 caractères, comme un message du panneau web.
				text = text.Trim();
				if ( text.Length > MessageMax )
					text = text[..MessageMax];
				target.Notify( $"Message du staff : {text}", "annonce" );
				done = $"Message envoyé à {target.RpName}.";
				break;

			case "envoyer":
			{
				// Envoie le joueur sur un autre joueur (le « tp » du panneau web).
				var dest = RoleplayPlayer.All.FirstOrDefault( x => x.SteamId.ToString() == text );
				if ( dest is null || dest == target )
				{
					staff.Notify( "Choisissez un autre joueur en ligne.", "erreur" );
					return;
				}
				target.TeleportTo( dest.WorldPosition + dest.WorldRotation.Forward.WithZ( 0 ).Normal * 60 );
				target.Notify( $"Le staff vous a envoyé auprès de {dest.RpName}.", "annonce" );
				done = $"{target.RpName} envoyé auprès de {dest.RpName}.";
				text = dest.RpName;
				break;
			}

			case "bannir":
				if ( target.IsStaff )
				{
					staff.Notify( "On ne bannit pas un membre du staff depuis le jeu.", "erreur" );
					return;
				}
				_ = BanAsync( staff, target, value, text );
				return;

			case "personnage":
				// Rouvre l'écran de création : nouveau nom, nouvelle apparence.
				target.NeedsCharacter = true;
				target.Notify( "Le staff vous permet de refaire votre personnage.", "annonce" );
				done = $"{target.RpName} peut refaire son personnage.";
				break;

			case "expulser":
				Connection.All.FirstOrDefault( x => x.SteamId == steamId )?.Kick( string.IsNullOrWhiteSpace( text ) ? "Expulsé par le staff" : text );
				done = $"{target.RpName} expulsé.";
				break;

			case "gel":
			case "degel":
				game.SetFrozen( target, action == "gel" );
				done = action == "gel" ? $"{target.RpName} est gelé." : $"{target.RpName} est dégelé.";
				break;

			case "reapparition":
				game.Respawn( target, "Le staff vous a fait réapparaître.", "annonce" );
				done = $"{target.RpName} renvoyé au point d'apparition.";
				break;

			case "tuer":
				if ( target.Downed )
				{
					staff.Notify( $"{target.RpName} est déjà inconscient.", "erreur" );
					return;
				}
				game.Down( target, $"tué par le staff ({staff.RpName})" );
				done = $"{target.RpName} inconscient.";
				break;

			case "reanimer":
				if ( !target.Downed )
				{
					staff.Notify( $"{target.RpName} n'est pas inconscient.", "erreur" );
					return;
				}
				game.Revive( target, staff );
				done = $"{target.RpName} réanimé.";
				break;

			case "objet":
			case "retirer":
			case "nom":
			case "capture":
			{
				var parts = (text ?? "").Split( '|' );
				var (ok, result) = action switch
				{
					"objet" => game.GiveItem( target, text, value ),
					"retirer" => game.TakeItem( target, text, value ),
					"nom" => parts.Length == 2 ? game.Rename( target, parts[0], parts[1] ) : (false, "Nom illisible"),
					_ => game.RequestCapture( target, Signature( staff ) ),
				};
				if ( !ok )
				{
					staff.Notify( result, "erreur" );
					return;
				}
				done = action == "capture" ? $"{result} : elle arrive dans le panneau web (fiche de {target.RpName})." : $"{target.RpName} : {result}.";
				break;
			}

			default:
				staff.Notify( $"Action inconnue : {action}", "erreur" );
				return;
		}

		game.AddLog( "staff_jeu", steamId, $"{action} par {Signature( staff )}{(value != 0 ? $" ({value})" : "")}{(string.IsNullOrWhiteSpace( text ) ? "" : $" : {text}")}" );
		staff.Notify( done, "info" );
	}

	/// <summary>Bannit : en base d'abord (comme le panneau), puis l'expulsion. Hors ligne, rien n'est fait.</summary>
	static async Task BanAsync( RoleplayPlayer staff, RoleplayPlayer target, int hours, string reason )
	{
		hours = Math.Clamp( hours, 0, 24 * 365 );
		reason = string.IsNullOrWhiteSpace( reason ) ? "Banni par le staff" : reason.Trim();
		var steamId = target.SteamId;
		var name = target.RpName;

		if ( !Backend.IsOnline )
		{
			staff.Notify( "Bannissement impossible sans la base (partie locale) : expulsez plutôt.", "erreur" );
			return;
		}

		var response = await Backend.StaffBan( steamId, hours, reason, Signature( staff ) );
		if ( response?.Ok != true )
		{
			staff.Notify( response?.Error ?? "API injoignable : personne n'a été banni.", "erreur" );
			return;
		}

		HorizonGame.Current?.BanNow( steamId, hours > 0 ? $"Banni {hours} h : {reason}" : $"Banni : {reason}" );
		staff.Notify( $"{name} est banni ({(hours > 0 ? $"{hours} h" : "définitif")}).", "info" );
	}

	// Journal et fiche, lus dans l'API pour le staff qui les demande

	/// <summary><paramref name="source"/> : « jeu », « admin » (le panneau web) ou "" pour tout, comme le filtre du panneau.</summary>
	[Rpc.Host]
	public static void RequestLogs( string q, ulong steamId, string source )
	{
		var staff = CallingStaff();
		if ( staff is null )
			return;
		_ = SendLogsAsync( staff, q, steamId, source is "jeu" or "admin" ? source : "" );
	}

	static async Task SendLogsAsync( RoleplayPlayer staff, string q, ulong steamId, string source )
	{
		var response = await Backend.StaffLogs( q, steamId, 120, source );
		if ( staff.Network.Owner is not { } connection )
			return;
		using ( Rpc.FilterInclude( connection ) )
			ToStaffLogs( steamId, response?.Ok == true ? Json.Serialize( response.Logs ) : null );
	}

	[Rpc.Broadcast( NetFlags.HostOnly )]
	static void ToStaffLogs( ulong steamId, string json )
	{
		AdminClient.Logs = json is null ? null : Json.Deserialize<List<Backend.StaffLog>>( json );
		AdminClient.LogsFor = steamId;
		AdminClient.Version++;
	}

	[Rpc.Host]
	public static void RequestPlayerFile( ulong steamId )
	{
		var staff = CallingStaff();
		if ( staff is null )
			return;
		_ = SendPlayerFileAsync( staff, steamId );
	}

	static async Task SendPlayerFileAsync( RoleplayPlayer staff, ulong steamId )
	{
		var response = await Backend.StaffFile( steamId );
		if ( !staff.IsValid() || staff.Network.Owner is not { } connection )
			return;
		using ( Rpc.FilterInclude( connection ) )
			ToStaffFile( steamId, response?.Ok == true ? Json.Serialize( response ) : null );
	}

	[Rpc.Broadcast( NetFlags.HostOnly )]
	static void ToStaffFile( ulong steamId, string json )
	{
		AdminClient.File = json is null ? null : Json.Deserialize<Backend.StaffFileResponse>( json );
		AdminClient.FileFor = steamId;
		AdminClient.Version++;
	}

	/// <summary>
	/// La recherche de joueurs en base, connectés ou non (nom RP, pseudo, SteamID) : retrouver un
	/// joueur parti pour le bannir, le débannir, le renommer ou fixer son argent.
	/// </summary>
	[Rpc.Host]
	public static void RequestFindPlayers( string q, string filter, int page )
	{
		var staff = CallingStaff();
		if ( staff is null )
			return;
		_ = SendPlayersAsync( staff, (q ?? "").Trim(), filter is "enligne" or "horsligne" or "bannis" ? filter : "tous", Math.Max( 1, page ) );
	}

	static async Task SendPlayersAsync( RoleplayPlayer staff, string q, string filter, int page )
	{
		var response = await Backend.StaffPlayers( q, filter, page );
		if ( !staff.IsValid() || staff.Network.Owner is not { } connection )
			return;
		using ( Rpc.FilterInclude( connection ) )
			ToStaffPlayers( response?.Ok == true ? Json.Serialize( response ) : null );
	}

	[Rpc.Broadcast( NetFlags.HostOnly )]
	static void ToStaffPlayers( string json )
	{
		AdminClient.Found = json is null ? null : Json.Deserialize<Backend.StaffPlayersResponse>( json );
		AdminClient.Version++;
	}

	/// <summary>
	/// Ce qui s'écrit en base sur la fiche d'un joueur : « debannir » (toujours), et pour un joueur
	/// qui n'est pas connecté « bannir » (heures dans <paramref name="value"/>, motif dans
	/// <paramref name="text"/>), « portefeuille » / « banque » (montant exact) et « nom »
	/// (« Prénom|Nom »). Un joueur connecté passe par <see cref="RequestPlayerAction"/> : le
	/// serveur de jeu reste l'autorité sur lui, sa sauvegarde écraserait sinon la base.
	/// </summary>
	[Rpc.Host]
	public static void RequestRecordAction( ulong steamId, string action, int value, string text )
	{
		var staff = CallingStaff();
		if ( staff is null )
			return;
		if ( !Backend.IsOnline )
		{
			staff.Notify( "Partie locale : pas de base, rien à écrire.", "erreur" );
			return;
		}

		var connected = RoleplayPlayer.All.FirstOrDefault( x => x.SteamId == steamId );
		if ( action == "debannir" )
		{
			_ = RecordAsync( staff, steamId, Backend.StaffUnban( steamId, Signature( staff ) ), "Il pourra se reconnecter d'ici quinze secondes." );
			return;
		}
		if ( connected is not null )
		{
			staff.Notify( $"{connected.RpName} est connecté : agissez depuis sa fiche en ligne.", "erreur" );
			return;
		}

		switch ( action )
		{
			case "bannir":
				// Même règle qu'en ligne : pas un membre du staff (la liste du serveur fait foi, connecté ou non).
				if ( Admins.IsAdmin( steamId ) )
				{
					staff.Notify( "On ne bannit pas un membre du staff depuis le jeu.", "erreur" );
					return;
				}
				var hours = Math.Clamp( value, 0, 24 * 365 );
				var reason = string.IsNullOrWhiteSpace( text ) ? "Banni par le staff" : text.Trim();
				// Refusé à la connexion tout de suite, sans attendre l'échange suivant.
				_ = RecordAsync( staff, steamId, Backend.StaffBanOffline( steamId, hours, reason, Signature( staff ) ), null,
					() => HorizonGame.Current?.BanNow( steamId, reason ) );
				return;

			case "portefeuille":
			case "banque":
				_ = RecordAsync( staff, steamId, Backend.StaffMoney( steamId, action, Math.Clamp( value, 0, MoneyMax ), Signature( staff ) ) );
				return;

			case "nom":
				_ = RecordAsync( staff, steamId, Backend.StaffRename( steamId, text ?? "", Signature( staff ) ) );
				return;

			default:
				staff.Notify( $"Action inconnue : {action}", "erreur" );
				return;
		}
	}

	/// <summary>Attend l'API, dit au staff ce qu'il en est, puis lui renvoie la fiche à jour.</summary>
	static async Task RecordAsync( RoleplayPlayer staff, ulong steamId, Task<Backend.StaffAnswer> call, string more = null, Action then = null )
	{
		var response = await call;
		if ( !staff.IsValid() )
			return;
		if ( response?.Ok != true )
		{
			staff.Notify( response?.Error ?? "API injoignable : rien n'a été écrit.", "erreur" );
			return;
		}
		then?.Invoke();
		staff.Notify( more is null ? response.Message ?? "Fait." : $"{response.Message} {more}", "info" );
		await SendPlayerFileAsync( staff, steamId );
	}

	[Rpc.Host]
	public static void RequestNotes( ulong steamId, string notes )
	{
		var staff = CallingStaff();
		if ( staff is null )
			return;
		_ = SaveNotesAsync( staff, steamId, notes );
	}

	static async Task SaveNotesAsync( RoleplayPlayer staff, ulong steamId, string notes )
	{
		var response = await Backend.StaffNotes( steamId, notes ?? "", Signature( staff ) );
		Answer( staff, response?.Ok == true ? null : response?.Error ?? "API injoignable : notes non enregistrées.", "Notes enregistrées." );
	}

	// Le serveur

	/// <summary>« redemarrer » ou « arreter » dans <paramref name="seconds"/> secondes, ou « annuler ».</summary>
	[Rpc.Host]
	public static void RequestServer( string mode, int seconds )
	{
		var staff = CallingStaff();
		var game = HorizonGame.Current;
		if ( staff is null || game is null )
			return;

		var refusal = mode == "annuler" ? game.CancelShutdown( Signature( staff ) ) : game.ScheduleShutdown( mode, seconds, Signature( staff ) );
		if ( refusal is not null )
			staff.Notify( refusal, "erreur" );
	}

	// Les portes, de n'importe où (le menu des portes, touche P, lui, demande d'être devant)

	[Rpc.Host]
	public static void RequestDoorRelease( string key )
	{
		var staff = CallingStaff();
		if ( staff is null || DoorSystem.Current is null )
			return;

		var owner = DoorSystem.Current.ReleaseByStaff( key, out var found );
		staff.Notify( !found ? "Porte introuvable." : owner is null ? "La porte était libre." : $"Porte reprise à {owner}.", found ? "info" : "erreur" );
		if ( found )
			HorizonGame.Current?.AddLog( "porte", staff.SteamId, $"Porte {key} reprise{(owner is null ? "" : $" à {owner}")} par {Signature( staff )}" );
	}

	[Rpc.Host]
	public static void RequestDoorLock( string key, bool locked )
	{
		var staff = CallingStaff();
		if ( staff is null || DoorSystem.Current is null )
			return;

		var found = DoorSystem.Current.LockByStaff( key, locked );
		staff.Notify( !found ? "Porte introuvable." : locked ? "Porte verrouillée." : "Porte déverrouillée.", found ? "info" : "erreur" );
	}

	// Le physgun du staff (StaffTool) : l'hôte déplace ce que le staff tient

	/// <summary>Ce que chaque staff tient, et comment le relâcher.</summary>
	sealed class Grab
	{
		public GameObject Target;
		public RoleplayPlayer Player;
		public Rigidbody Body;
		public bool WasMotionEnabled;
	}

	static readonly Dictionary<ulong, Grab> grabs = new();

	/// <summary>
	/// Ce qui se prend : un joueur (pas soi-même), ou un objet en réseau que
	/// l'hôte simule — objet de la ville, objet au sol, véhicule vide. Les décors
	/// de la carte ne sont pas en réseau : les bouger ne se verrait que chez soi.
	/// </summary>
	public static string CanGrab( GameObject go, RoleplayPlayer staff )
	{
		if ( !go.IsValid() )
			return "Rien à saisir";

		var player = go.GetComponentInParent<RoleplayPlayer>();
		if ( player.IsValid() )
			return player == staff ? "Impossible de se saisir soi-même" : null;

		var root = go.Root;
		if ( !root.Network.Active )
			return "Décor de la carte : il ne se déplace pas";

		var vehicle = root.GetComponent<Vehicle>();
		if ( vehicle.IsValid() && vehicle.DriverController.IsValid() )
			return "Véhicule occupé";

		return null;
	}

	[Rpc.Host]
	public static void PhysgunGrab( Guid id )
	{
		var staff = CallingStaff();
		if ( staff is null )
			return;

		var go = Game.ActiveScene.Directory.FindByGuid( id );
		var refusal = CanGrab( go, staff );
		if ( refusal is not null )
		{
			staff.Notify( refusal, "erreur" );
			return;
		}

		Release( staff.SteamId, freeze: false );

		var grab = new Grab { Player = go.GetComponentInParent<RoleplayPlayer>() };
		if ( grab.Player.IsValid() )
		{
			grab.Target = grab.Player.GameObject;
			grab.Player.HeldPosition = grab.Player.WorldPosition;
			grab.Player.Held = true;
		}
		else
		{
			grab.Target = go.Root;

			// Un objet tenu ne tombe plus : sa physique est suspendue le temps de la prise.
			grab.Body = grab.Target.GetComponent<Rigidbody>();
			if ( grab.Body.IsValid() )
			{
				grab.WasMotionEnabled = grab.Body.MotionEnabled;
				grab.Body.MotionEnabled = false;
			}

			// L'hôte doit en être le propriétaire pour que ses déplacements partent.
			if ( !grab.Target.Network.IsOwner )
				grab.Target.Network.TakeOwnership();
		}

		grabs[staff.SteamId] = grab;
	}

	[Rpc.Host( NetFlags.Unreliable )]
	public static void PhysgunMove( Vector3 position, Rotation rotation )
	{
		var staff = CallingStaff();
		if ( staff is null || !grabs.TryGetValue( staff.SteamId, out var grab ) || !grab.Target.IsValid() )
			return;

		// Pas plus loin que la portée de l'outil : une position délirante ne passe pas.
		if ( position.Distance( staff.WorldPosition ) > StaffTool.MaxDistance + 200 )
			return;

		if ( grab.Player.IsValid() )
		{
			grab.Player.HeldPosition = position;
			return;
		}

		grab.Target.WorldPosition = position;
		grab.Target.WorldRotation = rotation;
	}

	/// <summary>Relâche ; <paramref name="freeze"/> laisse l'objet figé en l'air, ou le joueur gelé.</summary>
	[Rpc.Host]
	public static void PhysgunRelease( bool freeze )
	{
		var staff = CallingStaff();
		if ( staff is null )
			return;

		Release( staff.SteamId, freeze, staff );
	}

	static void Release( ulong staffId, bool freeze, RoleplayPlayer staff = null )
	{
		if ( !grabs.Remove( staffId, out var grab ) || !grab.Target.IsValid() )
			return;

		if ( grab.Player.IsValid() )
		{
			grab.Player.Held = false;
			grab.Player.TeleportTo( grab.Player.HeldPosition );
			if ( freeze )
				HorizonGame.Current?.SetFrozen( grab.Player, true );
			return;
		}

		if ( grab.Body.IsValid() )
		{
			// Un objet figé au physgun porte l'étiquette FrozenTag : repris puis lâché, il retrouve
			// sa physique. Sans elle, la reprise mémorisait « physique coupée » et l'objet restait
			// figé pour toujours (Julien, 29/10/2026).
			var wasFrozen = grab.Target.Tags.Has( FrozenTag );
			grab.Body.MotionEnabled = !freeze && (grab.WasMotionEnabled || wasFrozen);
			grab.Body.Velocity = Vector3.Zero;
			grab.Body.AngularVelocity = Vector3.Zero;
			grab.Target.Tags.Set( FrozenTag, freeze );
		}

		// Un objet de la ville déplacé au physgun garde sa nouvelle place.
		var city = grab.Target.GetComponent<CityObject>();
		if ( city.IsValid() && staff.IsValid() && CityManager.Current is not null )
			_ = MoveAsync( staff, city.PlacementId, grab.Target.WorldPosition, grab.Target.WorldRotation, grab.Target.WorldScale.x );
	}

	/// <summary>L'étiquette d'un objet figé au physgun (clic droit) : lui seul se dégèle.</summary>
	public const string FrozenTag = "fige_physgun";

	/// <summary>Dégèle un objet figé au physgun (clic droit dessus, sans rien tenir).</summary>
	[Rpc.Host]
	public static void PhysgunUnfreeze( Guid id )
	{
		var staff = CallingStaff();
		if ( staff is null )
			return;

		var go = Game.ActiveScene.Directory.FindByGuid( id );
		if ( !go.IsValid() || !go.Tags.Has( FrozenTag ) || go.GetComponent<Rigidbody>() is not { } body )
			return;

		go.Tags.Set( FrozenTag, false );
		body.MotionEnabled = true;
		staff.Notify( "Objet dégelé.", "info" );
	}

	/// <summary>Un staff qui part relâche ce qu'il tenait.</summary>
	public static void ReleaseAllOf( ulong staffId ) => Release( staffId, freeze: false );
}