Vehicles/VehicleManager.cs

Host-side vehicle manager for HorizonRP. Loads the vehicle catalog from the backend, handles purchases, spawning, storing, locking, keys, staff actions, garage persistence and vehicle snapshots.

NetworkingFile Access
namespace HorizonRP;

/// <summary>
/// La concession et les garages : acheter un véhicule, le sortir, le ranger, le
/// verrouiller. Tourne sur l'hôte. Les véhicules possédés vivent en base
/// (hz_vehicles) ; un seul peut être sorti à la fois par joueur, et il rentre
/// tout seul au garage quand son propriétaire se déconnecte.
///
/// Les achats et sorties se font depuis un point « Garage » posé en F6 (touche
/// E dessus). La liste des véhicules d'un joueur lui est envoyée dans
/// RoleplayPlayer.GarageJson.
/// </summary>
[Title( "Horizon RP – Véhicules" )]
[Icon( "garage" )]
public sealed class VehicleManager : Component, Component.INetworkListener
{
	public static VehicleManager Current { get; private set; }

	/// <summary>Distance maximale à un garage pour acheter, sortir ou ranger.</summary>
	public const float GarageReach = 400f;

	/// <summary>Distance maximale entre le joueur et son véhicule pour le ranger ou le verrouiller.</summary>
	public const float VehicleReach = 600f;

	public const int MaxVehiclesPerPlayer = 6;

	const string LocalFile = "vehicules.json";

	/// <summary>Hôte : les véhicules possédés, par joueur.</summary>
	readonly Dictionary<ulong, List<OwnedVehicle>> garages = new();

	public sealed class OwnedVehicle
	{
		public long Id { get; set; }
		public ulong SteamId { get; set; }
		public string ModelId { get; set; }
		public string Color { get; set; }
		public string Plate { get; set; }
		/// <summary>Le coffre et les dégâts (Vehicle.SavedState, en JSON) ; vide pour un véhicule neuf.</summary>
		public string State { get; set; }
	}

	protected override void OnAwake() => Current = this;

	// ------------------------------------------------------------------ fiches (base)

	/// <summary>Les fiches lues dans la base par l'hôte, partagées avec toutes les machines.</summary>
	[Sync( SyncFlags.FromHost )] public string ModelsJson { get; set; }

	string parsedJson;
	List<VehicleModel> parsed;
	string modelsVersion;
	bool loadingModels;

	/// <summary>Les fiches de la base (vide tant qu'elles ne sont pas lues : VehicleCatalog prend alors celles d'origine).</summary>
	public List<VehicleModel> Models
	{
		get
		{
			if ( ModelsJson != parsedJson )
			{
				parsedJson = ModelsJson;
				try { parsed = string.IsNullOrEmpty( ModelsJson ) ? null : Json.Deserialize<List<VehicleModel>>( ModelsJson ); }
				catch ( Exception e ) { Log.Warning( $"[Horizon] Fiches des véhicules illisibles : {e.Message}" ); parsed = null; }
			}
			return parsed;
		}
	}

	protected override void OnUpdate()
	{
		if ( Scene.IsEditor )
			return;

		// Le menu du coffre et du staff, ajouté au HUD à l'exécution (il n'est pas dans la scène).
		if ( !Application.IsDedicatedServer && !menuAdded && Scene.GetAllComponents<DoorMenu>().FirstOrDefault() is { } doors )
		{
			doors.GameObject.Components.GetOrCreate<VehicleMenu>();
			menuAdded = true;
		}

		// Partout : les clés tenues en main (modèle, pose, bras tendu, clic gauche), et les mains
		// de ceux qui ne sont plus au volant.
		Vehicle.ReleaseStrayHands();
		VehicleKeySystem.Update();
		// Partout (chez chaque tireur) : les balles reportées sur la tôle des véhicules. Reposé
		// à chaque image : un champ statique de SWB survit mal au rechargement à chaud.
		SWB.Base.Weapon.RefineBulletTrace = Vehicle.RefineBulletTrace;

		if ( !Networking.IsHost )
			return;

		// Une clé tenue qui a quitté les poches (jetée, donnée) n'est plus en main. Les billets
		// n'y sont jamais (c'est le portefeuille) : ils restent en main.
		foreach ( var player in RoleplayPlayer.All )
		{
			if ( !string.IsNullOrEmpty( player.HeldKey ) && !Cash.IsHeld( player ) && !player.Inventory.Any( x => x.Item == player.HeldKey ) )
				player.HeldKey = null;
		}

		if ( !Networking.IsActive || !Backend.IsOnline )
			return;
		if ( modelsVersion is null && !loadingModels )
			_ = LoadModels();
	}

	async Task LoadModels()
	{
		loadingModels = true;
		try
		{
			var response = await Backend.LoadVehicleModels();
			if ( response?.Models is null )
			{
				Log.Warning( "[Horizon] Fiches des véhicules indisponibles (API injoignable)" );
				modelsVersion = "";
				return;
			}
			ModelsJson = Json.Serialize( response.Models );
			modelsVersion = response.Version ?? "";
			Log.Info( $"[Horizon] {response.Models.Count} fiche(s) de véhicule" );
		}
		finally
		{
			loadingModels = false;
		}
	}

	/// <summary>Hôte, à chaque échange avec l'API : des fiches changées ailleurs (panneau web) sont relues.</summary>
	public void CheckVersion( string apiVersion )
	{
		if ( !string.IsNullOrEmpty( apiVersion ) && modelsVersion is not null && apiVersion != modelsVersion && !loadingModels )
			_ = LoadModels();
	}

	/// <summary>Une plaque à la californienne (1ABC234), sans I, O ni Q : comme l'API pour les achats.</summary>
	public static string RandomPlate()
	{
		const string letters = "ABCDEFGHJKLMNPRSTUVWXYZ";
		var r = Random.Shared;
		return $"{r.Int( 1, 9 )}{letters[r.Int( 0, letters.Length - 1 )]}{letters[r.Int( 0, letters.Length - 1 )]}{letters[r.Int( 0, letters.Length - 1 )]}{r.Int( 100, 999 )}";
	}

	/// <summary>
	/// Hôte : un véhicule apparaît devant un joueur, à la demande du staff (panneau web ou menu
	/// en jeu). Il est à ce joueur (il le verrouille), ouvert, avec une plaque tirée au hasard,
	/// et ne va dans aucun garage : il disparaît quand le joueur se déconnecte.
	/// </summary>
	public (bool, string) StaffSpawn( RoleplayPlayer player, string modelId, string color, string by )
	{
		var model = VehicleCatalog.Get( modelId );
		if ( model is null )
			return (false, $"Modèle inconnu : {modelId}");
		if ( string.IsNullOrEmpty( color ) || !color.StartsWith( "#" ) )
			color = VehicleCatalog.Colors[0].Hex;

		var forward = player.WorldRotation.Forward.WithZ( 0 ).Normal;
		// Juste devant le joueur, de travers (portière face à lui) : ~3,5 m, la demi-largeur d'une
		// voiture et un pas. À 260, il naissait à une quinzaine de mètres en comptant sa longueur.
		var spot = player.WorldPosition + forward * 140 + Vector3.Up * 60;
		var ground = Scene.Trace.Ray( spot, spot + Vector3.Down * 400 ).WithoutTags( "player", "trigger" ).Run();
		var go = new GameObject( true, "Véhicule (staff)" );
		go.WorldPosition = (ground.Hit ? ground.EndPosition : player.WorldPosition) + Vector3.Up * 30;
		go.WorldRotation = Rotation.FromYaw( player.WorldRotation.Angles().yaw + 90 );

		var plate = RandomPlate();
		var vehicle = go.Components.Create<Vehicle>();
		vehicle.VehicleId = 0;
		vehicle.OwnerId = player.SteamId;
		vehicle.OwnerName = player.RpName;
		vehicle.CatalogId = model.Id;
		vehicle.ColorHex = color;
		vehicle.Plate = plate;
		// Verrouillé, comme tout véhicule garé : la clé est donnée avec.
		vehicle.Locked = true;
		AddSeats( go, model );
		go.NetworkSpawn();
		go.Network.SetOwnerTransfer( OwnerTransfer.Fixed );

		player.Notify( $"Le staff vous a donné un véhicule : {model.Name} ({plate}).", "annonce" );
		VehicleKeys.Give( player, plate );
		HorizonGame.Current?.AddLog( "vehicule", player.SteamId, $"Apparition par {by} : {model.Name} {plate} ({color})" );
		return (true, $"{model.Name} ({plate}) devant {player.RpName}");
	}

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

	// Chargement des garages

	/// <summary>Appelé par HorizonGame une fois le profil du joueur chargé.</summary>
	public async Task LoadGarage( RoleplayPlayer player )
	{
		List<OwnedVehicle> owned;

		if ( Backend.IsOnline )
		{
			var response = await Backend.LoadVehicles( player.SteamId );
			if ( response?.Vehicles is null )
				return;
			owned = response.Vehicles;
		}
		else
		{
			owned = LocalAll().Where( x => x.SteamId == player.SteamId ).ToList();
		}

		if ( !player.IsValid() )
			return;

		garages[player.SteamId] = owned;
		Publish( player );
	}

	List<OwnedVehicle> LocalAll() => FileSystem.Data.ReadJsonOrDefault<List<OwnedVehicle>>( LocalFile ) ?? new();

	void Publish( RoleplayPlayer player )
	{
		player.GarageJson = Json.Serialize( garages.GetValueOrDefault( player.SteamId ) ?? new() );
	}

	// Connexions

	public void OnDisconnected( Connection channel )
	{
		ulong steamId = channel.SteamId;
		foreach ( var vehicle in Vehicle.All.Where( x => x.OwnerId == steamId ).ToList() )
			vehicle.Remove();

		garages.Remove( steamId );
	}

	// Demandes des joueurs (sur l'hôte)

	RoleplayPlayer Caller() =>
		RoleplayPlayer.All.FirstOrDefault( x => Rpc.Caller is not null && x.Network.OwnerId == Rpc.Caller.Id );

	/// <summary>Le garage (point posé en F6) près duquel se trouve le joueur.</summary>
	static CityObject NearGarage( RoleplayPlayer player ) =>
		CityObject.All
			.Where( x => x.Kind == CityKinds.Garage && x.WorldPosition.Distance( player.WorldPosition ) <= GarageReach )
			.OrderBy( x => x.WorldPosition.Distance( player.WorldPosition ) )
			.FirstOrDefault();

	[Rpc.Host]
	public void RequestBuy( string modelId, string color )
	{
		var player = Caller();
		var model = VehicleCatalog.Get( modelId );
		if ( player is null || model is null || !player.ProfileLoaded )
			return;

		// Une fiche hors vente (la flotte de police) ne s'achète pas, même par une requête forgée.
		if ( !model.Shop )
		{
			player.Notify( $"{model.Name} n'est pas en vente.", "erreur" );
			return;
		}

		if ( NearGarage( player ) is null )
		{
			player.Notify( "Rendez-vous à une concession pour acheter.", "erreur" );
			return;
		}

		var owned = garages.GetValueOrDefault( player.SteamId ) ?? new();
		if ( owned.Count >= MaxVehiclesPerPlayer )
		{
			player.Notify( $"Votre garage est plein ({MaxVehiclesPerPlayer} véhicules).", "erreur" );
			return;
		}

		if ( player.Bank < model.Price )
		{
			player.Notify( $"Il vous manque {Format.Money( model.Price - player.Bank )} en banque.", "erreur" );
			return;
		}

		if ( !VehicleCatalog.Colors.Any( x => x.Hex == color ) )
			color = VehicleCatalog.Colors[0].Hex;

		// Payé d'abord, rendu si l'enregistrement échoue : jamais de véhicule
		// gratuit, jamais d'argent perdu.
		Bank.Move( player, -model.Price, "carte", $"Concession · {model.Name}", "Concession Union City" );
		_ = BuyAsync( player, model, color );
	}

	async Task BuyAsync( RoleplayPlayer player, VehicleModel model, string color )
	{
		OwnedVehicle vehicle;

		if ( Backend.IsOnline )
		{
			var response = await Backend.AddVehicle( player.SteamId, model.Id, color );
			vehicle = response?.Vehicle;
		}
		else
		{
			var all = LocalAll();
			vehicle = new OwnedVehicle
			{
				Id = all.Count == 0 ? 1 : all.Max( x => x.Id ) + 1,
				SteamId = player.SteamId,
				ModelId = model.Id,
				Color = color,
				Plate = $"HZ-{Random.Shared.Int( 100, 999 )}-RP",
			};
			all.Add( vehicle );
			FileSystem.Data.WriteJson( LocalFile, all );
		}

		if ( !player.IsValid() )
			return;

		if ( vehicle is null )
		{
			Bank.Move( player, model.Price, "remboursement", $"Remboursement · {model.Name}", "Concession Union City" );
			player.Notify( "Achat impossible pour le moment, vous n'avez pas été débité.", "erreur" );
			return;
		}

		if ( !garages.TryGetValue( player.SteamId, out var list ) )
			garages[player.SteamId] = list = new();
		list.Add( vehicle );
		Publish( player );

		player.Notify( $"{model.Name} achetée ({vehicle.Plate}) : -{Format.Money( model.Price )}", "argent" );
		HorizonGame.Current?.AddLog( "vehicule", player.SteamId, $"Achat {model.Name} {vehicle.Plate} pour {Format.Money( model.Price )}" );
	}

	[Rpc.Host]
	public void RequestSpawn( long vehicleId )
	{
		var player = Caller();
		if ( player is null )
			return;

		var garage = NearGarage( player );
		var owned = garages.GetValueOrDefault( player.SteamId )?.FirstOrDefault( x => x.Id == vehicleId );
		if ( garage is null || owned is null )
		{
			player.Notify( "Sortez vos véhicules depuis un garage.", "erreur" );
			return;
		}

		// Un seul véhicule dehors : sortir le second range le premier.
		foreach ( var old in Vehicle.All.Where( x => x.OwnerId == player.SteamId ).ToList() )
			old.Remove();

		var model = VehicleCatalog.Get( owned.ModelId );
		if ( model is null )
		{
			player.Notify( "Ce modèle n'est plus au catalogue.", "erreur" );
			return;
		}

		// Devant le garage, dans son orientation, un peu au-dessus du sol.
		var position = garage.WorldPosition + garage.WorldRotation.Forward * 220 + Vector3.Up * 24;
		var go = new GameObject( true, $"Véhicule {owned.Plate}" );
		go.WorldPosition = position;
		go.WorldRotation = Rotation.FromYaw( garage.WorldRotation.Angles().yaw );

		var vehicle = go.Components.Create<Vehicle>();
		vehicle.VehicleId = owned.Id;
		vehicle.OwnerId = player.SteamId;
		vehicle.OwnerName = player.RpName;
		vehicle.CatalogId = owned.ModelId;
		vehicle.ColorHex = owned.Color;
		vehicle.Plate = owned.Plate;
		vehicle.Locked = true;
		vehicle.ApplyState( owned.State );

		AddSeats( go, model );

		go.NetworkSpawn();
		go.Network.SetOwnerTransfer( OwnerTransfer.Fixed );

		player.Notify( $"{model.Name} ({owned.Plate}) sortie, verrouillée : clé en main, clic gauche pour l'ouvrir.", "info" );
		// La clé reste avec l'objet : un joueur qui l'a donnée ou perdue en reçoit un double au garage.
		VehicleKeys.Give( player, owned.Plate );
	}

	/// <summary>Les sièges d'un véhicule, autant que sa fiche en prévoit (le premier est celui du conducteur).</summary>
	public static void AddSeats( GameObject vehicle, VehicleModel model )
	{
		var count = Math.Clamp( model?.Seats ?? 2, 1, 8 );
		for ( var i = 0; i < count; i++ )
		{
			var seat = new GameObject( vehicle, true, i == 0 ? "Siège conducteur" : $"Siège passager {i}" );
			var chair = seat.Components.Create<VehicleSeat>();
			chair.Index = i;
			// Tous assis comme le conducteur (Julien) : en « Chair », les passagers étaient affalés en arrière.
			chair.SitPose = BaseChair.AnimatorSitPose.ChairForward;
		}
	}

	bool menuAdded;

	/// <summary>
	/// Hôte : l'état d'un véhicule acheté (coffre, dégâts) est enregistré — en base, ou dans le
	/// fichier local hors ligne — et gardé pour sa prochaine sortie.
	/// </summary>
	public void SaveState( Vehicle vehicle )
	{
		if ( vehicle is null || vehicle.VehicleId <= 0 )
			return;
		var json = vehicle.StateJson();
		var owned = garages.GetValueOrDefault( vehicle.OwnerId )?.FirstOrDefault( x => x.Id == vehicle.VehicleId );
		if ( owned is not null )
			owned.State = json;

		if ( Backend.IsOnline )
		{
			_ = Backend.SaveVehicleState( vehicle.VehicleId, vehicle.OwnerId, json );
			return;
		}
		var all = LocalAll();
		if ( all.FirstOrDefault( x => x.Id == vehicle.VehicleId ) is { } local )
		{
			local.State = json;
			FileSystem.Data.WriteJson( LocalFile, all );
		}
	}

	/// <summary>Le menu de gestion du staff (G) : une action sur un véhicule, vérifiée et journalisée.</summary>
	[Rpc.Host]
	public void RequestStaffAction( Guid vehicleObject, string action )
	{
		var player = Caller();
		if ( player is null || !player.IsStaff )
			return;
		var vehicle = Vehicle.All.FirstOrDefault( x => x.GameObject.Id == vehicleObject );
		if ( vehicle is null )
			return;

		var (ok, message) = StaffAction( vehicle, action, player.RpName, player );
		player.Notify( message, ok ? "info" : "erreur" );
	}

	/// <summary>
	/// Menu admin en jeu (onglet Véhicules) : une fiche réglée. L'API la vérifie et l'enregistre
	/// (mêmes bornes que le panneau web), puis toutes les machines relisent les fiches.
	/// </summary>
	[Rpc.Host]
	public void RequestSaveModel( string json )
	{
		var player = Caller();
		if ( player is null || !player.IsStaff )
			return;
		VehicleSheet sheet;
		try { sheet = Json.Deserialize<VehicleSheet>( json ?? "" ); }
		catch { sheet = null; }
		if ( sheet is null || VehicleCatalog.Get( sheet.Id ) is null )
			return;
		_ = SaveModel( player, sheet.ToModel() );
	}

	async Task SaveModel( RoleplayPlayer player, VehicleModel model )
	{
		if ( !Backend.IsOnline )
		{
			player.Notify( "Partie locale : les fiches se règlent quand le serveur parle à l'API.", "erreur" );
			return;
		}
		var response = await Backend.SaveVehicleModel( model, $"{player.RpName} ({player.SteamId})" );
		if ( response?.Ok != true )
		{
			player.Notify( response?.Error ?? "API injoignable : fiche non enregistrée.", "erreur" );
			return;
		}
		player.Notify( $"{model.Name} enregistrée. Un véhicule déjà sorti garde ses anciennes valeurs jusqu'à être rangé.", "info" );
		HorizonGame.Current?.AddLog( "vehicule_catalogue", player.SteamId, $"Fiche {model.Id} réglée en jeu par {player.RpName}" );
		await LoadModels();
	}

	/// <summary>Menu admin en jeu (onglet Véhicules) : un véhicule devant soi, dans la couleur choisie.</summary>
	[Rpc.Host]
	public void RequestStaffSpawn( string modelId, string color )
	{
		var player = Caller();
		if ( player is null || !player.IsStaff )
			return;
		// StaffSpawn donne déjà la clé.
		var (ok, message) = StaffSpawn( player, modelId, color, player.RpName );
		if ( !ok )
			player.Notify( message, "erreur" );
	}

	/// <summary>Menu admin en jeu : un emplacement du coffre d'un véhicule, pris ou supprimé.</summary>
	[Rpc.Host]
	public void RequestStaffTrunk( Guid vehicleObject, int trunkSlot, string action )
	{
		var player = Caller();
		if ( player is null || !player.IsStaff )
			return;
		var vehicle = Vehicle.All.FirstOrDefault( x => x.GameObject.Id == vehicleObject );
		if ( vehicle is null )
			return;
		var (ok, message) = vehicle.StaffTrunk( trunkSlot, action, player.RpName, player );
		player.Notify( message, ok ? "info" : "erreur" );
	}

	/// <summary>Hôte : la photo des véhicules sortis, pour le panneau (positions au mètre près).</summary>
	public List<Backend.VehicleSnapshot> Snapshot() => Vehicle.All.Where( x => x.IsValid() ).OrderBy( x => x.Plate ).Select( v => new Backend.VehicleSnapshot
	{
		Plate = v.Plate,
		Model = v.CatalogId,
		Name = v.Info?.Name,
		Color = v.ColorHex,
		OwnerId = v.OwnerId,
		OwnerName = v.OwnerName,
		Bought = v.VehicleId > 0,
		Driver = v.DriverController?.GetComponent<RoleplayPlayer>()?.RpName,
		Passengers = v.Seats.Count( s => s.IsOccupied && !s.IsDriver ),
		Locked = v.Locked,
		EngineOn = v.EngineOn,
		Engine = (int)MathF.Round( v.EngineHealth / Vehicle.MaxHealth * 100 ),
		Body = (int)MathF.Round( v.BodyHealth / Vehicle.MaxHealth * 100 ),
		Trunk = v.TrunkSlots.Count,
		TrunkKg = MathF.Round( v.TrunkWeight, 1 ),
		TrunkItems = v.TrunkSlots.OrderBy( s => s.Slot ).Select( s => new Backend.TrunkItem { Slot = s.Slot, Item = s.Item, Name = s.Definition?.Name ?? s.Item, Count = s.Count } ).ToList(),
		X = (int)MathF.Round( v.WorldPosition.x / 40 ) * 40,
		Y = (int)MathF.Round( v.WorldPosition.y / 40 ) * 40,
		Z = (int)MathF.Round( v.WorldPosition.z / 40 ) * 40,
	} ).ToList();

	/// <summary>
	/// Hôte : une action du staff sur un véhicule, depuis le menu G ou le panneau web (ordre
	/// « vehicule_gestion »). Journalisée au nom du propriétaire.
	/// </summary>
	public (bool, string) StaffAction( Vehicle vehicle, string action, string by, RoleplayPlayer caller = null )
	{
		string done;
		switch ( action )
		{
			case "reparer":
				vehicle.Repair();
				done = "réparé";
				break;
			case "retourner":
				vehicle.Flip();
				done = "remis sur ses roues";
				break;
			case "verrouiller":
			case "ouvrir":
				vehicle.Locked = action == "verrouiller";
				done = vehicle.Locked ? "verrouillé" : "déverrouillé";
				break;
			case "moteur":
				vehicle.StaffEngineOff();
				done = "moteur coupé";
				break;
			case "cle":
				if ( caller is null )
					return (false, "Un double se donne depuis le jeu (menu G)");
				VehicleKeys.Give( caller, vehicle.Plate );
				done = "double de la clé donné";
				break;
			case "ranger":
				vehicle.Remove();
				done = "rangé";
				break;
			default:
				return (false, $"Action inconnue : {action}");
		}

		HorizonGame.Current?.AddLog( "vehicule_staff", vehicle.OwnerId, $"{by} : {vehicle.Plate} {done}" );
		return (true, $"{vehicle.Plate} : {done}.");
	}

	[Rpc.Host]
	public void RequestStore()
	{
		var player = Caller();
		if ( player is null )
			return;

		var vehicle = Vehicle.All.FirstOrDefault( x => x.OwnerId == player.SteamId );
		if ( vehicle is null )
		{
			player.Notify( "Vous n'avez pas de véhicule dehors.", "erreur" );
			return;
		}

		if ( NearGarage( player ) is null || vehicle.WorldPosition.Distance( player.WorldPosition ) > VehicleReach * 2 )
		{
			player.Notify( "Ramenez votre véhicule près d'un garage pour le ranger.", "erreur" );
			return;
		}

		vehicle.Remove();
		player.Notify( "Véhicule rangé.", "info" );
	}

	/// <summary>
	/// L, assis dans un véhicule : le bouton de la portière, sans clé, que le conducteur
	/// comme un passager peut presser. Seuls les occupants entendent le verrou. Dehors, il
	/// faut la clé en main (<see cref="RequestRemoteLock"/>).
	/// </summary>
	[Rpc.Host]
	public void RequestToggleLock()
	{
		var player = Caller();
		if ( player is null )
			return;

		var vehicle = player.GameObject.GetComponentInParent<Vehicle>()
			?? Vehicle.All.FirstOrDefault( v => v.Seats.Any( s => s.GetOccupant()?.GameObject == player.GameObject ) );
		if ( vehicle is null )
		{
			player.Notify( "Prenez la clé du véhicule en main pour le verrouiller à distance (clic gauche).", "info" );
			return;
		}

		vehicle.Locked = !vehicle.Locked;
		vehicle.PlayInteriorLock();
	}

	/// <summary>
	/// Clic gauche, la clé en main : verrouille ou déverrouille le véhicule de sa plaque, à
	/// portée de télécommande. Le bip et les clignotants s'entendent et se voient de partout.
	/// </summary>
	[Rpc.Host]
	public void RequestRemoteLock()
	{
		var player = Caller();
		var plate = VehicleKeys.PlateOf( player?.HeldKey );
		if ( plate is null || !player.Inventory.Any( x => x.Item == player.HeldKey ) )
			return;

		// Le bras se tend, qu'il y ait un véhicule au bout ou non.
		PlayKeyPress( player.GameObject.Id );

		var vehicle = Vehicle.All.FirstOrDefault( x => x.Plate == plate );
		if ( vehicle is null || vehicle.WorldPosition.Distance( player.WorldPosition ) > VehicleKeys.Range )
			return;

		vehicle.Locked = !vehicle.Locked;
		vehicle.PlayRemoteLock( vehicle.Locked );
	}

	[Rpc.Broadcast( NetFlags.HostOnly )]
	void PlayKeyPress( Guid playerObject ) => VehicleKeySystem.Pressed( playerObject );
}