Police/PoliceActions.cs

Host-side handlers for the police menu and locker. Validates each RPC caller is an on-duty cop with the right rank and in range, then applies cuff, escort, vehicle, frisk, seizure, fine, arrest, impound, lockpick, duty and station-point actions, writing journal logs and sending notifications to both parties.

Networking
namespace HorizonRP;

/// <summary>
/// Ce que le menu de police (F6, UI/PoliceMenu.razor) et le vestiaire demandent à l'hôte. Chaque
/// demande est revérifiée ici : l'appelant est un agent en service (<see cref="RoleplayPlayer.IsCop"/>),
/// en état d'agir, du grade voulu (PoliceRules.Rank*), à portée de la cible (~2,5 m) ; le client ne
/// désigne qu'un objet et des ids du code pénal, jamais un montant ni une peine (l'API les
/// recalcule). Tout est journalisé (logs, source « jeu ») et notifié aux deux parties.
///
/// Les noms des citoyens ne figurent pas dans les messages à l'agent : il ne connaît que ceux dont il
/// a vu les papiers (PoliceMenu.MarkKnown). Le journal, lui, a tout.
/// </summary>
public static class PoliceActions
{
	/// <summary>Portée d'une action sur un citoyen, vérifiée par l'hôte (pouces, ~2,8 m : le menu en vise 2,3).</summary>
	public const float Reach = 110f;

	/// <summary>Portée côté client, pour choisir le citoyen le plus proche (~2,3 m).</summary>
	public const float MenuReach = 90f;

	/// <summary>Distance au point d'écrou pour écrouer (pouces, ~9 m : le point est près des cellules).</summary>
	public const float BookingRange = 350f;

	/// <summary>Distance à la carrosserie pour agir sur un véhicule (pouces, ~3 m).</summary>
	public const float VehicleRange = 120f;

	/// <summary>La durée d'un crochetage (s).</summary>
	public const float LockpickSeconds = 5f;

	/// <summary>Nombre maximum de charges sur une amende ou une arrestation.</summary>
	public const int MaxCharges = 12;

	/// <summary>Plafond d'une peine (minutes), le même que l'API.</summary>
	public const int MaxJailMinutes = 120;

	/// <summary>Une note libre au casier.</summary>
	public const int NotesMax = 500;

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

	/// <summary>L'agent en service qui appelle, en état d'agir et du grade voulu ; sinon null (refus notifié).</summary>
	static RoleplayPlayer Cop( int minRank = 0, string what = null )
	{
		var player = Caller();
		if ( player is null )
			return null;
		if ( !player.IsCop )
		{
			player.Notify( "Réservé aux agents de l'UCPD en service.", "erreur" );
			return null;
		}
		if ( player.OnTheGround || player.Cuffed || player.Jailed || player.NeedsCharacter )
			return null;
		if ( player.PoliceGrade < minRank )
		{
			player.Notify( $"{what ?? "Cette action"} : grade de {PoliceRules.GradeOf( minRank ).Name} au moins.", "erreur" );
			return null;
		}
		return player;
	}

	/// <summary>Où l'on mesure un joueur : son corps s'il est à terre.</summary>
	static Vector3 At( RoleplayPlayer p ) => p.OnTheGround ? p.BodyPosition : p.WorldPosition;

	/// <summary>Le citoyen visé, à portée de l'agent ; sinon null (refus notifié).</summary>
	static RoleplayPlayer Target( RoleplayPlayer cop, Guid targetId )
	{
		var target = Game.ActiveScene?.Directory.FindByGuid( targetId )?.GetComponent<RoleplayPlayer>();
		if ( target is null || target == cop || !target.ProfileLoaded || target.NeedsCharacter )
			return null;
		if ( At( target ).Distance( cop.WorldPosition ) > Reach )
		{
			cop.Notify( "Approchez-vous du citoyen.", "erreur" );
			return null;
		}
		return target;
	}

	static string Who( RoleplayPlayer p ) => $"{p.RpName} ({p.SteamId})";

	static void Journal( string action, RoleplayPlayer subject, string details ) =>
		HorizonGame.Current?.AddLog( action, subject.SteamId, details );

	/// <summary>La distance à plat d'un point à la carrosserie d'un véhicule (0 dedans).</summary>
	public static float BodyDistance( Vehicle vehicle, Vector3 position )
	{
		if ( vehicle?.Rig is null || !vehicle.Ready )
			return float.MaxValue;
		var p = vehicle.WorldTransform.PointToLocal( position );
		var bb = vehicle.Rig.BodyBounds;
		var dx = MathF.Max( 0, MathF.Max( bb.Mins.x - p.x, p.x - bb.Maxs.x ) );
		var dy = MathF.Max( 0, MathF.Max( bb.Mins.y - p.y, p.y - bb.Maxs.y ) );
		return MathF.Sqrt( dx * dx + dy * dy );
	}

	/// <summary>Le véhicule le plus proche de ce point, à portée, ou null.</summary>
	public static Vehicle NearestVehicle( Vector3 position, float range ) =>
		Vehicle.All.Where( x => x.IsValid() && x.Ready )
			.Select( x => (Vehicle: x, Distance: BodyDistance( x, position )) )
			.Where( x => x.Distance <= range )
			.OrderBy( x => x.Distance )
			.Select( x => x.Vehicle )
			.FirstOrDefault();

	// ------------------------------------------------------------------ menottes, escorte, véhicule

	/// <summary>Menotter (mains dans le dos) ou démenotter. Le staff peut retirer des menottes oubliées.</summary>
	[Rpc.Host]
	public static void RequestCuff( Guid targetId, bool cuff )
	{
		var caller = Caller();
		var staffRelease = !cuff && caller is { IsStaff: true } && !caller.IsCop;
		var cop = staffRelease ? caller : Cop();
		if ( cop is null || Target( cop, targetId ) is not { } target || target.Cuffed == cuff )
			return;

		if ( cuff )
		{
			if ( target.Jailed )
				return;
			WeaponSystem.Current?.Holster( target );
			if ( !string.IsNullOrEmpty( target.HeldKey ) )
				target.HeldKey = null;
			target.Cuffed = true;
			PoliceService.Current?.NoteCuffs( target, cop );
			if ( PoliceService.SeatOf( target ) is { IsDriver: true } )
				PoliceService.Unseat( target );
			target.Notify( "Vous êtes menotté par l'UCPD.", "erreur" );
			cop.Notify( "Citoyen menotté.", "info" );
			Journal( "police_menottes", target, $"{PoliceService.JusticeSignature( cop )} menotte {Who( target )}" );
			return;
		}

		target.Cuffed = false;
		target.EscortedBy = Guid.Empty;
		PoliceService.Current?.NoteCuffs( target, null );
		target.Notify( "Vos menottes sont retirées.", "info" );
		cop.Notify( "Menottes retirées.", "info" );
		Journal( "police_menottes", target, $"{(staffRelease ? $"Le staff {Who( cop )}" : PoliceService.JusticeSignature( cop ))} démenotte {Who( target )}" );
	}

	/// <summary>Escorter un menotté (il suit l'agent, posé devant lui), ou le lâcher.</summary>
	[Rpc.Host]
	public static void RequestEscort( Guid targetId, bool escort )
	{
		var cop = Cop();
		if ( cop is null || Target( cop, targetId ) is not { } target )
			return;

		if ( !escort )
		{
			if ( target.EscortedBy == Guid.Empty )
				return;
			target.EscortedBy = Guid.Empty;
			cop.Notify( "Vous lâchez le citoyen.", "info" );
			return;
		}

		if ( !target.Cuffed )
		{
			cop.Notify( "On n'escorte qu'un citoyen menotté.", "erreur" );
			return;
		}
		if ( target.OnTheGround || PoliceService.SeatOf( target ) is not null || PoliceService.SeatOf( cop ) is not null )
			return;

		// Un seul à la fois : celui qu'il menait est lâché.
		foreach ( var other in RoleplayPlayer.All.Where( x => x.EscortedBy == cop.GameObject.Id && x != target ).ToList() )
			other.EscortedBy = Guid.Empty;
		target.EscortedBy = cop.GameObject.Id;
		target.Notify( "Un agent vous escorte.", "info" );
		cop.Notify( "Vous escortez le citoyen.", "info" );
		Journal( "police_escorte", target, $"{PoliceService.JusticeSignature( cop )} escorte {Who( target )}" );
	}

	/// <summary>Faire monter un menotté à l'arrière du véhicule le plus proche (sinon à l'avant, côté passager).</summary>
	[Rpc.Host]
	public static void RequestPutInVehicle( Guid targetId )
	{
		var cop = Cop();
		if ( cop is null || Target( cop, targetId ) is not { } target )
			return;
		if ( !target.Cuffed || target.OnTheGround || PoliceService.SeatOf( target ) is not null )
		{
			cop.Notify( "Seul un citoyen menotté et debout se fait monter en voiture.", "erreur" );
			return;
		}
		if ( NearestVehicle( cop.WorldPosition, VehicleRange ) is not { } vehicle )
		{
			cop.Notify( "Aucun véhicule à côté de vous.", "erreur" );
			return;
		}
		if ( vehicle.Locked || !vehicle.Usable )
		{
			cop.Notify( vehicle.Locked ? "Le véhicule est verrouillé." : "Ce véhicule est inutilisable.", "erreur" );
			return;
		}
		var seat = vehicle.Seats.Where( x => !x.IsOccupied && x.Index >= 2 ).OrderBy( x => x.Index ).FirstOrDefault()
			?? vehicle.Seats.FirstOrDefault( x => !x.IsOccupied && x.Index == 1 );
		if ( seat is null || target.GetComponent<PlayerController>() is not { } pc || target.Network.Owner is not { } owner )
		{
			cop.Notify( "Plus de place dans ce véhicule.", "erreur" );
			return;
		}

		target.EscortedBy = Guid.Empty;
		// Comme BaseChair.EnterChair : le siège est pris chez le joueur, qui simule son personnage.
		using ( Rpc.FilterInclude( owner ) )
			seat.Sit( pc );
		target.Notify( "On vous fait monter dans le véhicule.", "info" );
		cop.Notify( $"Citoyen installé dans {vehicle.Plate}.", "info" );
		Journal( "police_vehicule", target, $"{PoliceService.JusticeSignature( cop )} fait monter {Who( target )} dans {vehicle.Plate}" );
	}

	/// <summary>Faire descendre un citoyen d'un véhicule (le menotté ne descend pas seul).</summary>
	[Rpc.Host]
	public static void RequestTakeOutOfVehicle( Guid targetId )
	{
		var cop = Cop();
		var target = Game.ActiveScene?.Directory.FindByGuid( targetId )?.GetComponent<RoleplayPlayer>();
		if ( cop is null || target is null || target == cop || PoliceService.SeatOf( target ) is not { } seat )
			return;
		if ( BodyDistance( seat.Vehicle, cop.WorldPosition ) > VehicleRange )
		{
			cop.Notify( "Approchez-vous du véhicule.", "erreur" );
			return;
		}
		if ( seat.Vehicle.Locked )
		{
			cop.Notify( "Le véhicule est verrouillé.", "erreur" );
			return;
		}

		PoliceService.Unseat( target );
		target.Notify( "On vous fait descendre du véhicule.", "info" );
		cop.Notify( "Citoyen sorti du véhicule.", "info" );
		Journal( "police_vehicule", target, $"{PoliceService.JusticeSignature( cop )} fait descendre {Who( target )} de {seat.Vehicle.Plate}" );
	}

	// ------------------------------------------------------------------ fouille et identité

	/// <summary>Fouiller un menotté (ou un inconscient) : la fenêtre de fouille s'ouvre chez l'agent.</summary>
	[Rpc.Host]
	public static void RequestFrisk( Guid targetId )
	{
		var cop = Cop();
		if ( cop is null || Target( cop, targetId ) is not { } target )
			return;
		if ( !target.Cuffed && !target.Downed )
		{
			cop.Notify( "Menottez le citoyen avant de le fouiller.", "erreur" );
			return;
		}
		cop.PoliceOpenSearch( target.GameObject.Id );
		target.Notify( "Un agent vous fouille.", "info" );
		Journal( "police_fouille", target, $"{PoliceService.JusticeSignature( cop )} fouille {Who( target )} : {target.Inventory.Count} poche(s), {Format.Money( target.Wallet )} sur lui" );
	}

	/// <summary>La cible d'une saisie : un menotté ou un inconscient, à portée.</summary>
	static (RoleplayPlayer Cop, RoleplayPlayer Target) SeizePair( Guid targetId )
	{
		var cop = Cop();
		if ( cop is null || Target( cop, targetId ) is not { } target || (!target.Cuffed && !target.Downed) )
			return (null, null);
		return (cop, target);
	}

	// Les saisies ne vont plus dans les poches de l'agent : elles partent sous scellé (registre des
	// scellés, Police/PoliceService.Evidence.cs), un numéro de scellé par objet, notifié à l'agent.
	// Sans API (partie locale), pas de registre : on ne saisit pas.

	static bool SealsAvailable( RoleplayPlayer cop )
	{
		if ( Backend.IsOnline )
			return true;
		cop.Notify( "Partie locale : pas de registre des scellés, saisie impossible ici.", "erreur" );
		return false;
	}

	/// <summary>Saisir une poche du citoyen fouillé : elle part sous scellé (avec ses données : une arme garde sa série).</summary>
	[Rpc.Host]
	public static void RequestSeizeItem( Guid targetId, int slotIndex )
	{
		var (cop, target) = SeizePair( targetId );
		if ( cop is null || !SealsAvailable( cop ) || Seize( target, slotIndex ) is not { } taken )
			return;
		PoliceService.SealSeized( cop, target, new() { taken.Item }, 0, taken.Label );
		Journal( "police_saisie", target, $"{PoliceService.JusticeSignature( cop )} saisit {taken.Label} sur {Who( target )} (mise sous scellé)" );
		HorizonGame.Current?.SaveNow( target );
	}

	/// <summary>Saisir l'argent liquide du citoyen fouillé : il part sous scellé.</summary>
	[Rpc.Host]
	public static void RequestSeizeCash( Guid targetId )
	{
		var (cop, target) = SeizePair( targetId );
		if ( cop is null || target.Wallet <= 0 || !SealsAvailable( cop ) )
			return;
		var amount = target.Wallet;
		target.Wallet = 0;
		var label = $"{Format.Money( amount )} en liquide";
		PoliceService.SealSeized( cop, target, new(), amount, label );
		Journal( "police_saisie", target, $"{PoliceService.JusticeSignature( cop )} saisit {label} sur {Who( target )} (mise sous scellé)" );
		HorizonGame.Current?.SaveNow( target );
	}

	/// <summary>Tout saisir : l'argent et chaque poche, dans une seule mise sous scellé.</summary>
	[Rpc.Host]
	public static void RequestSeizeAll( Guid targetId )
	{
		var (cop, target) = SeizePair( targetId );
		if ( cop is null || !SealsAvailable( cop ) )
			return;
		var labels = new List<string>();
		var items = new List<Backend.MdtEvidenceItem>();
		var cash = target.Wallet;
		if ( cash > 0 )
		{
			labels.Add( $"{Format.Money( cash )} en liquide" );
			target.Wallet = 0;
		}
		foreach ( var slot in target.Inventory.OrderBy( x => x.Slot ).Select( x => x.Slot ).ToList() )
		{
			if ( Seize( target, slot ) is not { } t )
				continue;
			items.Add( t.Item );
			labels.Add( t.Label );
		}
		if ( labels.Count == 0 )
		{
			cop.Notify( "Rien à saisir.", "info" );
			return;
		}
		var list = string.Join( ", ", labels );
		PoliceService.SealSeized( cop, target, items, cash, list );
		Journal( "police_saisie", target, $"{PoliceService.JusticeSignature( cop )} saisit sur {Who( target )} : {list} (mise sous scellé)" );
		HorizonGame.Current?.SaveNow( target );
	}

	/// <summary>Sort une poche entière du citoyen (rangée si elle était en main) : ce qui partira sous scellé.</summary>
	static (Backend.MdtEvidenceItem Item, string Label)? Seize( RoleplayPlayer target, int slotIndex )
	{
		var slot = target.SlotAt( slotIndex );
		var item = slot?.Definition;
		if ( item is null || slot.Count <= 0 )
			return null;
		if ( target.HeldKey == slot.Item )
			target.HeldKey = null;
		WeaponSystem.Current?.HolsterIfActive( target, slot );
		var count = slot.Count;
		var data = slot.Data;
		target.RemoveFrom( slot, count );
		var entry = new Backend.MdtEvidenceItem { Item = item.Id, Count = count, Data = data };
		return (entry, PoliceService.EvidenceLabel( item.Id, count, data, 0 ));
	}

	/// <summary>Contrôle d'identité : la carte d'identité du citoyen s'affiche chez l'agent.</summary>
	[Rpc.Host]
	public static void RequestIdentity( Guid targetId )
	{
		var cop = Cop();
		if ( cop is null || Target( cop, targetId ) is not { } target || cop.Network.Owner is not { } connection )
			return;
		using ( Rpc.FilterInclude( connection ) )
			target.PolicePresentId();
		target.Notify( "Un agent de l'UCPD contrôle votre identité.", "info" );
		Journal( "police_identite", target, $"{PoliceService.JusticeSignature( cop )} contrôle l'identité de {Who( target )}" );
	}

	// ------------------------------------------------------------------ amende et écrou

	/// <summary>Les lignes du code pénal choisies (ids séparés par des virgules), sans doublon ; null si une est inconnue.</summary>
	static List<PoliceService.PenalEntry> Charges( string ids )
	{
		var code = PoliceService.Current?.PenalCode;
		if ( code is null )
			return null;
		var list = new List<PoliceService.PenalEntry>();
		foreach ( var id in (ids ?? "").Split( ',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries ).Distinct() )
		{
			if ( code.FirstOrDefault( x => x.Id == id ) is not { } entry )
				return null;
			list.Add( entry );
		}
		return list.Count > MaxCharges ? null : list;
	}

	static string CleanNotes( string notes )
	{
		notes = (notes ?? "").Trim();
		return notes.Length > NotesMax ? notes[..NotesMax] : notes;
	}

	/// <summary>« Excès de vitesse (+2) » : le titre d'une ligne du casier.</summary>
	static string TitleOf( List<PoliceService.PenalEntry> charges ) =>
		charges.Count == 1 ? charges[0].Title : $"{charges[0].Title} (+{charges.Count - 1})";

	static string LocationOf( RoleplayPlayer cop ) =>
		PoliceService.Near( cop, ServerSettings.PoliceBooking, BookingRange )
			? "Commissariat (écrou)"
			: $"Union City ({cop.WorldPosition.x:0}, {cop.WorldPosition.y:0})";

	/// <summary>Une écriture au casier à la fois par agent (un double clic ne dresse pas deux amendes).</summary>
	static readonly HashSet<ulong> writing = new();

	/// <summary>Dresser une amende : contraventions et délits seulement, le montant calculé par l'API.</summary>
	[Rpc.Host]
	public static void RequestFine( Guid targetId, string chargeIds, string notes )
	{
		var cop = Cop( PoliceRules.RankFine, "Dresser une amende" );
		if ( cop is null || Target( cop, targetId ) is not { } target )
			return;
		var charges = Charges( chargeIds );
		if ( charges is null || charges.Count == 0 )
		{
			cop.Notify( "Choisissez au moins une infraction du code pénal.", "erreur" );
			return;
		}
		if ( charges.Any( x => x.Class == "crime" ) )
		{
			cop.Notify( "Un crime ne se règle pas par une amende : écrouez.", "erreur" );
			return;
		}
		if ( !Backend.IsOnline )
		{
			cop.Notify( "Partie locale : le casier est en base, amende impossible ici.", "erreur" );
			return;
		}
		if ( !writing.Add( cop.SteamId ) )
			return;
		_ = FineAsync( cop, target, charges, CleanNotes( notes ) );
	}

	static async Task FineAsync( RoleplayPlayer cop, RoleplayPlayer target, List<PoliceService.PenalEntry> charges, string notes )
	{
		try
		{
			var response = await Backend.JusticeRecord( target.SteamId, "amende", charges.Select( x => x.Id ).ToList(), TitleOf( charges ),
				notes, LocationOf( cop ), cop.SteamId, PoliceService.JusticeSignature( cop ) );
			if ( !cop.IsValid() )
				return;
			if ( response?.Ok != true )
			{
				cop.Notify( response?.Error ?? "Le fichier ne répond pas : amende non enregistrée.", "erreur" );
				return;
			}

			var total = response.FineTotal > 0 ? response.FineTotal : charges.Sum( x => x.Fine );
			var titles = string.Join( ", ", charges.Select( x => x.Title ) );
			if ( target.IsValid() )
			{
				target.Notify( $"Amende de {Format.Money( total )} ({titles}). Payez-la sur votre téléphone (appli Factures) ou à l'accueil du commissariat.", "argent" );
				PhoneSystem.Current?.RefreshInvoices( target );
			}
			cop.Notify( $"Amende de {Format.Money( total )} dressée (casier n° {response.RecordId}).", "info" );
			cop.PoliceKnow( target.SteamId );
			Journal( "police_amende", target, $"{PoliceService.JusticeSignature( cop )} dresse une amende de {Format.Money( total )} à {Who( target )} : {string.Join( ", ", charges.Select( x => x.Id ) )}" );
		}
		finally
		{
			writing.Remove( cop.SteamId );
		}
	}

	/// <summary>Écrouer un menotté au point d'écrou : charges au casier, amende éventuelle, prison.</summary>
	[Rpc.Host]
	public static void RequestArrest( Guid targetId, string chargeIds, string notes )
	{
		var cop = Cop( PoliceRules.RankArrest, "Écrouer" );
		if ( cop is null || Target( cop, targetId ) is not { } target )
			return;
		if ( !target.Cuffed )
		{
			cop.Notify( "Menottez le citoyen avant de l'écrouer.", "erreur" );
			return;
		}
		if ( target.Jailed )
			return;
		if ( !PoliceService.Near( cop, ServerSettings.PoliceBooking, BookingRange ) )
		{
			cop.Notify( "On écroue au point d'écrou du commissariat, près des cellules.", "erreur" );
			return;
		}
		if ( ServerSettings.PolicePrison is null )
		{
			cop.Notify( "La prison n'est pas encore placée : le staff doit poser son point.", "erreur" );
			return;
		}
		var charges = Charges( chargeIds );
		if ( charges is null || charges.Count == 0 )
		{
			cop.Notify( "Choisissez au moins une charge du code pénal.", "erreur" );
			return;
		}
		var minutes = Math.Min( MaxJailMinutes, charges.Sum( x => x.JailMinutes ) );
		if ( minutes <= 0 )
		{
			cop.Notify( "Ces charges ne prévoient pas de prison : dressez une amende.", "erreur" );
			return;
		}
		if ( !Backend.IsOnline )
		{
			cop.Notify( "Partie locale : le casier est en base, écrou impossible ici.", "erreur" );
			return;
		}
		if ( !writing.Add( cop.SteamId ) )
			return;
		_ = ArrestAsync( cop, target, charges, minutes, CleanNotes( notes ) );
	}

	static async Task ArrestAsync( RoleplayPlayer cop, RoleplayPlayer target, List<PoliceService.PenalEntry> charges, int minutes, string notes )
	{
		try
		{
			var response = await Backend.JusticeRecord( target.SteamId, "arrestation", charges.Select( x => x.Id ).ToList(), TitleOf( charges ),
				notes, LocationOf( cop ), cop.SteamId, PoliceService.JusticeSignature( cop ) );
			if ( response?.Ok != true )
			{
				if ( cop.IsValid() )
					cop.Notify( response?.Error ?? "Le fichier ne répond pas : écrou non enregistré.", "erreur" );
				return;
			}

			// Parti entre-temps : la peine est en base (hz_jail), elle le reprendra à sa reconnexion.
			if ( !target.IsValid() )
				return;

			var jail = Math.Clamp( response.JailMinutes > 0 ? response.JailMinutes : minutes, 1, MaxJailMinutes );
			var fine = response.FineTotal;
			// La peine qui fait foi est celle de l'API (hz_jail) : elle s'ajoute à une peine en cours.
			var seconds = response.Jail is { SecondsLeft: > 0 } total ? total.SecondsLeft : jail * 60;
			PoliceService.Current?.Imprison( target, seconds, response.Jail?.SecondsTotal ?? 0 );

			var titles = string.Join( ", ", charges.Select( x => x.Title ) );
			var fineText = fine > 0 ? $" Amende de {Format.Money( fine )} à payer (téléphone ou accueil du commissariat)." : "";
			target.Notify( $"Vous êtes écroué : {jail} min de prison, en jeu ({titles}).{fineText}", "erreur" );
			if ( cop.IsValid() )
			{
				cop.Notify( $"Citoyen écroué : {jail} min{(fine > 0 ? $", amende de {Format.Money( fine )}" : "")} (casier n° {response.RecordId}).", "info" );
				cop.PoliceKnow( target.SteamId );
			}
			Journal( "police_arrestation", target, $"{(cop.IsValid() ? PoliceService.JusticeSignature( cop ) : "Un agent")} écroue {Who( target )} : {jail} min, amende {Format.Money( fine )} ({string.Join( ", ", charges.Select( x => x.Id ) )})" );
		}
		finally
		{
			writing.Remove( cop.SteamId );
		}
	}

	// ------------------------------------------------------------------ véhicules

	/// <summary>Fourrière : un véhicule vide, acheté par un joueur, rangé dans son garage.</summary>
	[Rpc.Host]
	public static void RequestImpound( Guid vehicleId )
	{
		var cop = Cop( PoliceRules.RankImpound, "La fourrière" );
		var vehicle = Game.ActiveScene?.Directory.FindByGuid( vehicleId )?.GetComponent<Vehicle>();
		if ( cop is null || vehicle is null || !vehicle.Ready )
			return;
		if ( BodyDistance( vehicle, cop.WorldPosition ) > VehicleRange )
		{
			cop.Notify( "Approchez-vous du véhicule.", "erreur" );
			return;
		}
		if ( vehicle.Seats.Any( x => x.IsOccupied ) )
		{
			cop.Notify( "Le véhicule doit être vide.", "erreur" );
			return;
		}
		if ( vehicle.IsPolice )
		{
			cop.Notify( "Un véhicule de la flotte se range au garage de la police.", "erreur" );
			return;
		}
		if ( vehicle.VehicleId <= 0 || vehicle.OwnerId == 0 )
		{
			cop.Notify( "Ce véhicule n'appartient à personne (staff) : pas de fourrière.", "erreur" );
			return;
		}

		var plate = vehicle.Plate;
		var ownerId = vehicle.OwnerId;
		var ownerName = vehicle.OwnerName;
		vehicle.Remove();
		cop.Notify( $"{plate} mis en fourrière.", "info" );
		RoleplayPlayer.All.FirstOrDefault( x => x.SteamId == ownerId )?.Notify( $"Votre véhicule {plate} a été mis en fourrière par l'UCPD : il vous attend au garage.", "annonce" );
		HorizonGame.Current?.AddLog( "police_fourriere", ownerId, $"{PoliceService.JusticeSignature( cop )} met {plate} ({ownerName}) en fourrière" );
	}

	static readonly HashSet<ulong> picking = new();

	/// <summary>Crocheter un véhicule verrouillé (quelques secondes à côté de la portière).</summary>
	[Rpc.Host]
	public static void RequestLockpick( Guid vehicleId )
	{
		var cop = Cop();
		var vehicle = Game.ActiveScene?.Directory.FindByGuid( vehicleId )?.GetComponent<Vehicle>();
		if ( cop is null || vehicle is null || !vehicle.Ready )
			return;
		if ( BodyDistance( vehicle, cop.WorldPosition ) > VehicleRange )
		{
			cop.Notify( "Approchez-vous du véhicule.", "erreur" );
			return;
		}
		if ( !vehicle.Locked )
		{
			cop.Notify( "Le véhicule n'est pas verrouillé.", "info" );
			return;
		}
		if ( !picking.Add( cop.SteamId ) )
			return;
		_ = LockpickAsync( cop, vehicle );
	}

	static async Task LockpickAsync( RoleplayPlayer cop, Vehicle vehicle )
	{
		try
		{
			cop.Notify( $"Crochetage en cours ({LockpickSeconds:0} s) : restez près du véhicule.", "info" );
			await GameTask.DelaySeconds( LockpickSeconds );
			if ( !cop.IsValid() || !vehicle.IsValid() || !cop.IsCop || cop.OnTheGround )
				return;
			if ( BodyDistance( vehicle, cop.WorldPosition ) > VehicleRange )
			{
				cop.Notify( "Crochetage interrompu : vous vous êtes éloigné.", "erreur" );
				return;
			}
			vehicle.Locked = false;
			cop.Notify( $"{vehicle.Plate} déverrouillé.", "info" );
			HorizonGame.Current?.AddLog( "police_crochetage", vehicle.OwnerId, $"{PoliceService.JusticeSignature( cop )} crochète {vehicle.Plate} ({vehicle.OwnerName})" );
		}
		finally
		{
			picking.Remove( cop.SteamId );
		}
	}

	// ------------------------------------------------------------------ vestiaire, armurerie, accueil

	static bool AtLocker( RoleplayPlayer player )
	{
		if ( PoliceService.Near( player, ServerSettings.PoliceLocker, PoliceRules.PointRange * 1.5f ) )
			return true;
		player.Notify( "Rendez-vous au vestiaire du commissariat.", "erreur" );
		return false;
	}

	/// <summary>Prendre ou finir son service (au vestiaire).</summary>
	[Rpc.Host]
	public static void RequestDuty( bool on )
	{
		var player = Caller();
		if ( player is null || PoliceService.Current is not { } service || !AtLocker( player ) )
			return;
		if ( on )
			service.StartService( player );
		else if ( player.OnDuty )
			service.EndService( player, "fin de service au vestiaire" );
	}

	/// <summary>Mettre ou retirer la tenue de service (au vestiaire, en service).</summary>
	[Rpc.Host]
	public static void RequestUniform( bool on )
	{
		var cop = Cop();
		if ( cop is null || !AtLocker( cop ) || PoliceService.Current is not { } service )
			return;
		service.SetUniform( cop, on );
	}

	/// <summary>Une arme de l'armurerie (PoliceRules.Armory).</summary>
	[Rpc.Host]
	public static void RequestArmory( string weaponId )
	{
		var cop = Cop();
		if ( cop is null || !AtLocker( cop ) )
			return;
		PoliceService.Current?.IssueWeapon( cop, weaponId );
	}

	/// <summary>Rendre tout l'armement, sans quitter le service.</summary>
	[Rpc.Host]
	public static void RequestReturnWeapons()
	{
		var player = Caller();
		if ( player is null || player.PoliceGrade < 0 || !AtLocker( player ) )
			return;
		PoliceService.Current?.ReturnWeapons( player );
	}

	static bool AtDesk( RoleplayPlayer player ) =>
		PoliceService.Near( player, ServerSettings.PoliceFrontDesk, PoliceRules.PointRange * 1.5f );

	/// <summary>L'accueil du commissariat : la liste de ses amendes impayées.</summary>
	[Rpc.Host]
	public static void RequestDeskFines()
	{
		var player = Caller();
		if ( player is null || !AtDesk( player ) )
			return;
		_ = PoliceService.Current?.SendDeskFines( player );
	}

	/// <summary>L'accueil du commissariat : payer une amende depuis HZ BANK.</summary>
	[Rpc.Host]
	public static void RequestDeskPay( long fineId )
	{
		var player = Caller();
		if ( player is null || fineId <= 0 || !AtDesk( player ) )
			return;
		_ = PoliceService.Current?.PayDeskFine( player, fineId );
	}

	// ------------------------------------------------------------------ points du commissariat (staff)

	/// <summary>Les points uniques, et leur nom.</summary>
	public static (string Key, string Label, string Icon)[] Points => new (string, string, string)[]
	{
		(PoliceRules.PointLocker, "Vestiaire (service, armurerie)", "checkroom"),
		(PoliceRules.PointGarage, "Garage de la police (menu)", "garage"),
		(PoliceRules.PointBooking, "Écrou (près des cellules)", "gavel"),
		(PoliceRules.PointJail, "Prison (arrivée des détenus, centre de l'enceinte)", "lock"),
		(PoliceRules.PointRelease, "Sortie de prison", "logout"),
		(PoliceRules.PointFrontDesk, "Accueil (payer ses amendes)", "support_agent"),
		(ServerSettings.PolicePointEvidence, "Salle des scellés (registre)", "inventory_2"),
	};

	/// <summary>Les listes de points (ajouter / retirer), leur nom au singulier et leur plafond.</summary>
	public static (string Key, string Label, string Icon, int Max)[] PointLists => new (string, string, string, int)[]
	{
		(PoliceRules.PointsGarageSpawn, "Sortie du garage", "local_parking", 10),
		(ServerSettings.PolicePointComputers, "Poste informatique", "computer", 12),
	};

	/// <summary>
	/// Le staff pose un point du commissariat là où il se tient : <paramref name="mode"/> « poser »
	/// pour un point unique ; pour les sorties du garage, « ajouter » ou « retirer:N ».
	/// </summary>
	[Rpc.Host]
	public static void RequestStaffPoint( string key, string mode )
	{
		var staff = Caller();
		if ( staff is null || !staff.IsStaff )
			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 ( Points.FirstOrDefault( x => x.Key == key ) is { Key: not null } single )
		{
			if ( mode != "poser" )
				return;
			_ = SaveSetting( staff, key, new { x = here.X, y = here.Y, z = here.Z, yaw = here.Yaw }, ServerSettings.PointJson( here ), $"Point posé ici : {single.Label}." );
			return;
		}

		if ( PointLists.FirstOrDefault( x => x.Key == key ) is not { Key: not null } kind )
			return;
		var points = ServerSettings.PolicePointList( key );
		if ( mode == "ajouter" )
		{
			if ( points.Count >= kind.Max )
			{
				staff.Notify( $"{kind.Max} points « {kind.Label} » 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, key, list, ServerSettings.PointsJson( points ), mode == "ajouter" ? $"{kind.Label} ajouté ici." : $"{kind.Label} retiré." );
	}

	/// <summary>Enregistre un point par l'API (ou localement sans elle), comme AdminActions le fait pour l'hôpital.</summary>
	static async Task 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;
		}
		var response = await Backend.SaveSetting( key, value, $"{staff.RpName} ({staff.SteamId})" );
		if ( response?.Ok != true )
		{
			staff.Notify( response?.Error ?? "API injoignable : point non enregistré.", "erreur" );
			return;
		}
		ServerSettings.SetLocal( key, json );
		staff.Notify( done, "info" );
		HorizonGame.Current?.AddLog( "police_points", staff.SteamId, $"{Who( staff )} : {key} — {done}" );
	}
}