Police/PoliceService.Tablet.cs

Host-side police service for the MDT tablet. Handles citizen searches, files, plate lookups, warrants, roster changes and plate reader reads, sending each response back only to the requesting officer.

Networking
namespace HorizonRP;

/// <summary>
/// Côté agent : ce que l'hôte a lu dans l'API pour son MDT (tablette F7, ordinateur de bord,
/// poste du commissariat). Rien n'est gardé d'une partie à l'autre ; tout est redemandé à
/// l'ouverture de l'écran qui s'en sert. Les réponses sont rangées par question (la fiche d'un
/// citoyen par SteamID, une recherche par texte…) : sur le poste du commissariat, deux fenêtres
/// ouvertes sur deux fiches ne se volent pas leur réponse.
/// </summary>
public static class PoliceTabletClient
{
	/// <summary>Les recherches de citoyens, par texte cherché ; absente tant qu'elle n'est pas arrivée.</summary>
	public static Dictionary<string, List<Backend.MdtPerson>> Searches { get; } = new();
	public static Dictionary<string, string> SearchErrors { get; } = new();

	/// <summary>Les fiches (serverPoliceCitizen), par SteamID ; absente pendant le chargement ou en cas d'échec.</summary>
	public static Dictionary<ulong, Backend.MdtCitizenResponse> Files { get; } = new();
	public static Dictionary<ulong, string> FileErrors { get; } = new();

	/// <summary>Les plaques interrogées (serverPolicePlate), par plaque.</summary>
	public static Dictionary<string, Backend.MdtPlateVehicle> Plates { get; } = new();
	public static Dictionary<string, string> PlateErrors { get; } = new();

	/// <summary>La dernière plaque lue par le lecteur de l'ordinateur de bord (trace de l'hôte) ; vide : rien dans l'axe.</summary>
	public static string PlateRead { get; set; }
	public static int PlateReadSerial { get; set; }

	/// <summary>Une page de l'historique du 911 (<see cref="HistoryFor"/> : « service|statut|texte|page »).</summary>
	public static List<Backend.MdtDispatchRow> History { get; set; }
	public static string HistoryFor { get; set; }
	public static int HistoryTotal { get; set; }
	public static int HistoryPerPage { get; set; }
	public static string HistoryError { get; set; }

	/// <summary>Les pages du registre des scellés, par question (« statut|propriétaire|texte|page »).</summary>
	public static Dictionary<string, Backend.MdtEvidenceResponse> Evidence { get; } = new();

	/// <summary>Monte quand un scellé change (clôture) : les écrans redemandent leurs pages sans attendre.</summary>
	public static int EvidenceSerial { get; set; }

	/// <summary>Les avis de recherche actifs ; <see cref="WarrantsMissing"/> si l'API ne sait pas les lister.</summary>
	public static List<Backend.MdtWarrant> Warrants { get; set; }
	public static bool WarrantsMissing { get; set; }

	/// <summary>Les effectifs, connectés ou non, avec leur nom.</summary>
	public static List<MdtOfficerRow> Roster { get; set; }

	/// <summary>Change à chaque réponse : la tablette se redessine.</summary>
	public static int Version { get; set; }
}

/// <summary>Un inscrit de l'UCPD tel que l'onglet Effectifs le montre.</summary>
public sealed class MdtOfficerRow
{
	public string SteamId { get; set; }
	public string Name { get; set; }
	public int Grade { get; set; }
	public string Badge { get; set; }
	public string Callsign { get; set; }
	public bool Online { get; set; }
	public bool OnDuty { get; set; }

	public ulong Id => ulong.TryParse( SteamId, out var id ) ? id : 0;
}

/// <summary>
/// Les demandes de la tablette de police, sur l'hôte. Chaque appel revérifie que l'appelant est
/// un agent en service (<see cref="RoleplayPlayer.IsCop"/>) et que son grade le permet
/// (<see cref="PoliceRules"/>) : l'onglet caché côté client n'est qu'un confort. Les réponses
/// ne partent qu'au demandeur (Rpc.FilterInclude), comme le menu d'administration.
/// </summary>
public sealed partial class PoliceService
{
	/// <summary>Un champ de texte libre de la tablette (rapport, motif d'avis de recherche).</summary>
	public const int MdtTextMax = 2000;

	/// <summary>L'agent en service qui appelle, ou null (refus notifié) ; <paramref name="minRank"/> : le grade exigé.</summary>
	static RoleplayPlayer MdtCaller( int minRank = 0, string what = null )
	{
		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 )
			return null;

		if ( !player.IsCop )
		{
			Log.Warning( $"[Horizon] Tablette de police refusée à {caller.DisplayName} ({caller.SteamId}) : pas en service" );
			player.Notify( "Tablette réservée aux agents de l'UCPD en service.", "erreur" );
			return null;
		}

		if ( player.PoliceGrade < minRank )
		{
			player.Notify( $"{what ?? "Cette action"} : grade de {PoliceRules.GradeOf( minRank ).Name} au moins.", "erreur" );
			return null;
		}

		if ( !Backend.IsOnline )
		{
			player.Notify( "Partie locale : le fichier de police est en base, rien à lire ici.", "erreur" );
			return null;
		}

		return player;
	}

	/// <summary>« Sergent John Doe (1042) » : la signature des écritures, dans le journal et le casier.</summary>
	public static string MdtSignature( RoleplayPlayer cop ) =>
		$"{PoliceRules.GradeOf( cop.PoliceGrade ).Name} {cop.RpName} ({(string.IsNullOrEmpty( cop.PoliceBadge ) ? cop.SteamId.ToString() : cop.PoliceBadge)})";

	static void MdtSend( RoleplayPlayer cop, Action send )
	{
		if ( !cop.IsValid() || cop.Network.Owner is not { } connection )
			return;
		using ( Rpc.FilterInclude( connection ) )
			send();
	}

	/// <summary>Les charges envoyées par la tablette (« PC-211,VC-22350 ») : seulement des lignes connues du code pénal.</summary>
	static List<string> MdtCharges( string csv )
	{
		var known = Current?.PenalCode;
		return (csv ?? "").Split( ',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries )
			.Where( id => id.Length <= 16 && (known is null || known.Count == 0 || known.Any( p => p.Id == id )) )
			.Distinct()
			.Take( 20 )
			.ToList();
	}

	static string MdtClip( string text, int max )
	{
		text = (text ?? "").Trim();
		return text.Length > max ? text[..max] : text;
	}

	/// <summary>Une plaque telle que la base la range (policePlate d'api.php) : 1 à 8 lettres ou chiffres, en majuscules.</summary>
	public static string MdtPlateOf( string plate )
	{
		var clean = new string( (plate ?? "").ToUpperInvariant().Where( c => c is (>= 'A' and <= 'Z') or (>= '0' and <= '9') ).ToArray() );
		return clean.Length is 0 or > 8 ? null : clean;
	}

	/// <summary>Un indicatif radio (« 2-ADAM-12 ») : majuscules, chiffres et tirets, 12 caractères.</summary>
	public static string MdtCallsignOf( string callsign )
	{
		var clean = new string( (callsign ?? "").ToUpperInvariant().Where( c => char.IsLetterOrDigit( c ) || c == '-' ).ToArray() );
		return clean.Length > 12 ? clean[..12] : clean;
	}

	// Lectures

	[Rpc.Host]
	public static void MdtRequestSearch( string query )
	{
		var cop = MdtCaller();
		query = MdtClip( query, 64 );
		if ( cop is null || query.Length == 0 )
			return;
		_ = MdtSearchAsync( cop, query );
	}

	static async Task MdtSearchAsync( RoleplayPlayer cop, string query )
	{
		var response = await Backend.MdtSearch( query );
		var json = response?.Ok == true ? Json.Serialize( response.People ?? new() ) : null;
		var error = response is null ? "Le fichier ne répond pas." : response.Ok ? null : response.Error ?? "Recherche refusée.";
		MdtSend( cop, () => MdtToSearch( query, json, error ) );
	}

	[Rpc.Broadcast( NetFlags.HostOnly )]
	static void MdtToSearch( string query, string json, string error )
	{
		if ( json is null )
		{
			PoliceTabletClient.Searches.Remove( query );
			PoliceTabletClient.SearchErrors[query] = error;
		}
		else
		{
			PoliceTabletClient.Searches[query] = Json.Deserialize<List<Backend.MdtPerson>>( json ) ?? new();
			PoliceTabletClient.SearchErrors.Remove( query );
		}
		PoliceTabletClient.Version++;
	}

	[Rpc.Host]
	public static void MdtRequestCitizen( ulong steamId )
	{
		var cop = MdtCaller();
		if ( cop is null || steamId == 0 )
			return;
		_ = MdtCitizenAsync( cop, steamId );
	}

	static async Task MdtCitizenAsync( RoleplayPlayer cop, ulong steamId )
	{
		var response = await Backend.MdtCitizenFile( steamId );
		if ( response?.Ok == true && !string.IsNullOrWhiteSpace( response.Citizen?.RpName ) )
			knownNames[steamId.ToString()] = response.Citizen.RpName;
		var json = response?.Ok == true ? Json.Serialize( response ) : null;
		var error = response is null ? "Le fichier ne répond pas." : response.Ok ? null : response.Error ?? "Fiche introuvable.";
		MdtSend( cop, () => MdtToCitizen( steamId, json, error ) );
	}

	[Rpc.Broadcast( NetFlags.HostOnly )]
	static void MdtToCitizen( ulong steamId, string json, string error )
	{
		var file = json is null ? null : Json.Deserialize<Backend.MdtCitizenResponse>( json );
		if ( file is null )
		{
			PoliceTabletClient.Files.Remove( steamId );
			PoliceTabletClient.FileErrors[steamId] = error ?? "Fiche illisible.";
		}
		else
		{
			PoliceTabletClient.Files[steamId] = file;
			PoliceTabletClient.FileErrors.Remove( steamId );
		}
		PoliceTabletClient.Version++;
	}

	[Rpc.Host]
	public static void MdtRequestPlate( string plate )
	{
		var cop = MdtCaller();
		plate = MdtPlateOf( plate );
		if ( cop is null || plate is null )
			return;
		_ = MdtPlateAsync( cop, plate );
	}

	static async Task MdtPlateAsync( RoleplayPlayer cop, string plate )
	{
		var response = await Backend.MdtPlate( plate );
		var json = response?.Ok == true && response.Vehicle is not null ? Json.Serialize( response.Vehicle ) : null;
		var error = response is null ? "Le fichier ne répond pas." : !response.Ok ? response.Error : response.Vehicle is null ? "Aucun véhicule immatriculé sous cette plaque." : null;
		MdtSend( cop, () => MdtToPlate( plate, json, error ) );
	}

	[Rpc.Broadcast( NetFlags.HostOnly )]
	static void MdtToPlate( string plate, string json, string error )
	{
		var vehicle = json is null ? null : Json.Deserialize<Backend.MdtPlateVehicle>( json );
		if ( vehicle is null )
		{
			PoliceTabletClient.Plates.Remove( plate );
			PoliceTabletClient.PlateErrors[plate] = error ?? "Réponse illisible.";
		}
		else
		{
			PoliceTabletClient.Plates[plate] = vehicle;
			PoliceTabletClient.PlateErrors.Remove( plate );
		}
		PoliceTabletClient.Version++;
	}

	/// <summary>Portée du lecteur de plaques de l'ordinateur de bord (pouces, ~40 m) et son cône (cosinus, ~14°).</summary>
	public const float PlateReaderRange = 1600f;
	public const float PlateReaderCone = 0.97f;

	/// <summary>
	/// Le véhicule que lit le lecteur de plaques d'une voiture de police : le plus proche dans le cône
	/// avant (ou arrière), à portée, sans compter la voiture elle-même. Calculé par l'hôte pour la
	/// lecture demandée, par l'ordinateur de bord pour l'affichage en continu.
	/// </summary>
	public static Vehicle PlateTarget( Vehicle car, bool rear = false )
	{
		if ( !car.IsValid() )
			return null;
		var from = car.WorldPosition + Vector3.Up * 30;
		var axis = rear ? car.WorldRotation.Backward : car.WorldRotation.Forward;
		return Vehicle.All
			.Where( v => v.IsValid() && v != car && !string.IsNullOrWhiteSpace( v.Plate ) )
			.Select( v => (Vehicle: v, Offset: v.WorldPosition + Vector3.Up * 30 - from) )
			.Where( x => x.Offset.Length > 60 && x.Offset.Length < PlateReaderRange && Vector3.Dot( x.Offset.Normal, axis ) > PlateReaderCone )
			.OrderBy( x => x.Offset.Length )
			.Select( x => x.Vehicle )
			.FirstOrDefault();
	}

	/// <summary>Un véhicule de police (flotte de l'UCPD ou livrée de police ; pas une ambulance).</summary>
	public static bool IsPatrolCar( Vehicle car ) => car.IsValid() && car.HasBeacons && !car.EmsColors;

	/// <summary>
	/// L'ordinateur de bord lit la plaque du véhicule devant (ou derrière) : l'hôte refait la lecture
	/// depuis la voiture où l'agent est assis, puis interroge le fichier comme une recherche de plaque.
	/// </summary>
	[Rpc.Host]
	public static void MdtReadPlateAhead( bool rear )
	{
		var cop = MdtCaller();
		if ( cop is null )
			return;
		var car = cop.GetComponentInParent<Vehicle>();
		if ( !IsPatrolCar( car ) )
		{
			cop.Notify( "Le lecteur de plaques équipe les véhicules de police.", "erreur" );
			return;
		}
		var plate = MdtPlateOf( PlateTarget( car, rear )?.Plate );
		MdtSend( cop, () => MdtToPlateRead( plate ?? "" ) );
		if ( plate is null )
			return;
		HorizonGame.Current?.AddLog( "police_lecture_plaque", 0, $"{plate} lue par {MdtSignature( cop )}" );
		_ = MdtPlateAsync( cop, plate );
	}

	[Rpc.Broadcast( NetFlags.HostOnly )]
	static void MdtToPlateRead( string plate )
	{
		PoliceTabletClient.PlateRead = plate;
		PoliceTabletClient.PlateReadSerial++;
		PoliceTabletClient.Version++;
		if ( string.IsNullOrEmpty( plate ) )
			HudNotifications.Push( "Lecteur de plaques : aucun véhicule dans l'axe.", "info" );
	}

	[Rpc.Host]
	public static void MdtRequestWarrants()
	{
		var cop = MdtCaller();
		if ( cop is not null )
			_ = MdtWarrantsAsync( cop );
	}

	static async Task MdtWarrantsAsync( RoleplayPlayer cop )
	{
		var response = await Backend.MdtWarrants();
		var list = response?.Ok == true ? response.Warrants ?? new() : null;
		// L'API ne rend pas toujours le nom : celui d'un joueur en ville le complète.
		foreach ( var w in list ?? new() )
			if ( string.IsNullOrWhiteSpace( w.RpName ) && RoleplayPlayer.All.FirstOrDefault( x => x.SteamId.ToString() == w.Steam ) is { } p )
				w.RpName = p.RpName;
		var json = list is null ? null : Json.Serialize( list );
		MdtSend( cop, () => MdtToWarrants( json ) );
	}

	[Rpc.Broadcast( NetFlags.HostOnly )]
	static void MdtToWarrants( string json )
	{
		PoliceTabletClient.Warrants = json is null ? null : Json.Deserialize<List<Backend.MdtWarrant>>( json );
		PoliceTabletClient.WarrantsMissing = json is null;
		PoliceTabletClient.Version++;
	}

	/// <summary>
	/// Les noms déjà lus (agents hors ligne, fiches ouvertes), une fois par partie : la liste de
	/// synchronisation des effectifs n'a que le SteamID.
	/// </summary>
	static readonly Dictionary<string, string> knownNames = new();

	[Rpc.Host]
	public static void MdtRequestRoster()
	{
		var cop = MdtCaller();
		if ( cop is not null )
			_ = MdtRosterAsync( cop );
	}

	static async Task MdtRosterAsync( RoleplayPlayer cop )
	{
		var officers = Current?.Officers?.ToList() ?? new();
		foreach ( var p in RoleplayPlayer.All.Where( x => x.ProfileLoaded && !string.IsNullOrWhiteSpace( x.RpName ) ) )
			knownNames[p.SteamId.ToString()] = p.RpName;

		// Les noms qui manquent : une recherche par SteamID chacun, en parallèle, une seule fois.
		var missing = officers.Where( o => !knownNames.ContainsKey( o.SteamId ) ).Take( 40 ).ToList();
		if ( missing.Count > 0 )
		{
			// Toutes lancées d'abord, attendues ensuite : elles courent en même temps (Task.WhenAll
			// n'est pas permis par la liste blanche de s&box).
			var pending = missing.Select( o => Backend.MdtSearch( o.SteamId ) ).ToList();
			for ( var i = 0; i < missing.Count; i++ )
			{
				var answer = await pending[i];
				var person = answer?.People?.FirstOrDefault( x => x.Steam == missing[i].SteamId );
				if ( !string.IsNullOrWhiteSpace( person?.RpName ) )
					knownNames[missing[i].SteamId] = person.RpName;
			}
		}

		var rows = officers.Select( o =>
		{
			var online = RoleplayPlayer.All.FirstOrDefault( x => x.SteamId.ToString() == o.SteamId );
			return new MdtOfficerRow
			{
				SteamId = o.SteamId,
				Name = knownNames.GetValueOrDefault( o.SteamId ) ?? $"Agent {o.Badge}",
				Grade = o.Grade,
				Badge = o.Badge,
				Callsign = o.Callsign,
				Online = online is not null,
				OnDuty = online?.IsCop == true,
			};
		} ).OrderByDescending( x => x.Grade ).ThenBy( x => x.Name ).ToList();

		var json = Json.Serialize( rows );
		MdtSend( cop, () => MdtToRoster( json ) );
	}

	[Rpc.Broadcast( NetFlags.HostOnly )]
	static void MdtToRoster( string json )
	{
		PoliceTabletClient.Roster = Json.Deserialize<List<MdtOfficerRow>>( json ) ?? new();
		PoliceTabletClient.Version++;
	}

	// Écritures

	/// <summary>
	/// Un rapport ou un avertissement au casier d'un citoyen (les amendes et les arrestations se
	/// dressent sur le terrain, menu F6). Les charges ne servent ici qu'à qualifier les faits.
	/// </summary>
	[Rpc.Host]
	public static void MdtWriteRecord( ulong steamId, string kind, string charges, string title, string notes, string location )
	{
		var cop = MdtCaller();
		if ( cop is null || steamId == 0 || kind is not ("rapport" or "avertissement") )
			return;
		title = MdtClip( title, 160 );
		if ( title.Length == 0 )
		{
			cop.Notify( "Donnez un titre au rapport.", "erreur" );
			return;
		}
		_ = MdtRecordAsync( cop, steamId, kind, MdtCharges( charges ), title, MdtClip( notes, MdtTextMax ), MdtClip( location, 80 ) );
	}

	static async Task MdtRecordAsync( RoleplayPlayer cop, ulong steamId, string kind, List<string> charges, string title, string notes, string location )
	{
		var response = await Backend.MdtRecordWrite( steamId, kind, charges, title, notes, location, cop.SteamId, $"{PoliceRules.GradeOf( cop.PoliceGrade ).Name} {cop.RpName}" );
		if ( !cop.IsValid() )
			return;
		if ( response?.Ok != true )
		{
			cop.Notify( response?.Error ?? "Le fichier ne répond pas : rien n'est écrit.", "erreur" );
			return;
		}
		cop.Notify( kind == "rapport" ? $"Rapport n°{response.RecordId} versé au dossier." : $"Avertissement n°{response.RecordId} versé au dossier.", "info" );
		HorizonGame.Current?.AddLog( "police_" + kind, steamId, $"{title} — par {MdtSignature( cop )}" );
		await MdtCitizenAsync( cop, steamId );
	}

	[Rpc.Host]
	public static void MdtIssueWarrant( ulong steamId, string reason, string charges, bool dangerous )
	{
		var cop = MdtCaller( PoliceRules.RankWarrant, "Émettre un avis de recherche" );
		if ( cop is null || steamId == 0 )
			return;
		reason = MdtClip( reason, MdtTextMax );
		if ( reason.Length == 0 )
		{
			cop.Notify( "Un avis de recherche a besoin d'un motif.", "erreur" );
			return;
		}
		_ = MdtWarrantAsync( cop, steamId, reason, MdtCharges( charges ), dangerous );
	}

	static async Task MdtWarrantAsync( RoleplayPlayer cop, ulong steamId, string reason, List<string> charges, bool dangerous )
	{
		var response = await Backend.MdtWarrantIssue( steamId, reason, charges, dangerous, MdtSignature( cop ) );
		if ( !cop.IsValid() )
			return;
		if ( response?.Ok != true )
		{
			cop.Notify( response?.Error ?? "Le fichier ne répond pas : avis non émis.", "erreur" );
			return;
		}

		// Toutes les unités en service sont prévenues, comme un avis diffusé sur les ondes.
		var name = RoleplayPlayer.All.FirstOrDefault( x => x.SteamId == steamId )?.RpName ?? MdtNameOf( steamId );
		foreach ( var unit in RoleplayPlayer.All.Where( x => x.IsCop ) )
			unit.Notify( $"UCPD : avis de recherche n°{response.WarrantId}{(dangerous ? " (individu dangereux)" : "")} — {name}", "annonce" );
		HorizonGame.Current?.AddLog( "police_avis", steamId, $"Avis n°{response.WarrantId} : {reason} — par {MdtSignature( cop )}" );
		await MdtCitizenAsync( cop, steamId );
		await MdtWarrantsAsync( cop );
	}

	/// <summary>Le nom d'un citoyen hors ligne, tel que l'hôte l'a déjà lu (sinon son SteamID).</summary>
	static string MdtNameOf( ulong steamId ) => knownNames.GetValueOrDefault( steamId.ToString() ) ?? $"citoyen {steamId}";

	/// <summary><paramref name="steamId"/> : la fiche à rafraîchir ensuite (0 si on était sur la liste).</summary>
	[Rpc.Host]
	public static void MdtCloseWarrant( long warrantId, ulong steamId )
	{
		var cop = MdtCaller( PoliceRules.RankCloseWarrant, "Clore un avis de recherche" );
		if ( cop is null || warrantId <= 0 )
			return;
		_ = MdtCloseWarrantAsync( cop, warrantId, steamId );
	}

	static async Task MdtCloseWarrantAsync( RoleplayPlayer cop, long warrantId, ulong steamId )
	{
		var response = await Backend.MdtWarrantClose( warrantId, MdtSignature( cop ) );
		if ( !cop.IsValid() )
			return;
		if ( response?.Ok != true )
		{
			cop.Notify( response?.Error ?? "Le fichier ne répond pas : l'avis reste actif.", "erreur" );
			return;
		}
		cop.Notify( $"Avis de recherche n°{warrantId} clos.", "info" );
		HorizonGame.Current?.AddLog( "police_avis_clos", steamId, $"Avis n°{warrantId} clos par {MdtSignature( cop )}" );
		if ( steamId != 0 )
			await MdtCitizenAsync( cop, steamId );
		await MdtWarrantsAsync( cop );
	}

	/// <summary>Signale une plaque (motif « volé », « recherché »…) ; motif vide = retirer le signalement.</summary>
	[Rpc.Host]
	public static void MdtSetFlag( string plate, string reason )
	{
		var cop = MdtCaller();
		plate = MdtPlateOf( plate );
		if ( cop is null || plate is null )
			return;
		_ = MdtFlagAsync( cop, plate, MdtClip( reason, 160 ) );
	}

	static async Task MdtFlagAsync( RoleplayPlayer cop, string plate, string reason )
	{
		var response = await Backend.MdtFlagPlate( plate, reason, MdtSignature( cop ) );
		if ( !cop.IsValid() )
			return;
		if ( response?.Ok != true )
		{
			cop.Notify( response?.Error ?? "Le fichier ne répond pas : signalement non enregistré.", "erreur" );
			return;
		}
		cop.Notify( reason.Length == 0 ? $"Plaque {plate} : signalement retiré." : $"Plaque {plate} signalée : {reason}.", "info" );
		HorizonGame.Current?.AddLog( "police_plaque", 0, $"{plate} : {(reason.Length == 0 ? "signalement retiré" : reason)} — par {MdtSignature( cop )}" );
		// La liste publiée (Flags) se relit tout de suite (serverPoliceSync, PoliceService.Service.cs).
		Current?.RequestSync();
		await MdtPlateAsync( cop, plate );
	}

	[Rpc.Host]
	public static void MdtDeleteRecord( long recordId, ulong steamId )
	{
		var cop = MdtCaller( PoliceRules.RankDeleteRecord, "Effacer une ligne du casier" );
		if ( cop is null || recordId <= 0 )
			return;
		_ = MdtDeleteRecordAsync( cop, recordId, steamId );
	}

	static async Task MdtDeleteRecordAsync( RoleplayPlayer cop, long recordId, ulong steamId )
	{
		var response = await Backend.MdtRecordDelete( recordId, MdtSignature( cop ) );
		if ( !cop.IsValid() )
			return;
		if ( response?.Ok != true )
		{
			cop.Notify( response?.Error ?? "Le fichier ne répond pas : rien n'est effacé.", "erreur" );
			return;
		}
		cop.Notify( $"Ligne n°{recordId} effacée du casier.", "info" );
		HorizonGame.Current?.AddLog( "police_casier_efface", steamId, $"Ligne n°{recordId} effacée par {MdtSignature( cop )}" );
		if ( steamId != 0 )
			await MdtCitizenAsync( cop, steamId );
	}

	[Rpc.Host]
	public static void MdtCancelFine( long fineId, ulong steamId )
	{
		var cop = MdtCaller( PoliceRules.RankCancelFine, "Annuler une amende" );
		if ( cop is null || fineId <= 0 )
			return;
		_ = MdtCancelFineAsync( cop, fineId, steamId );
	}

	static async Task MdtCancelFineAsync( RoleplayPlayer cop, long fineId, ulong steamId )
	{
		var response = await Backend.MdtFineCancel( fineId, MdtSignature( cop ) );
		if ( !cop.IsValid() )
			return;
		if ( response?.Ok != true )
		{
			cop.Notify( response?.Error ?? "Le fichier ne répond pas : l'amende reste due.", "erreur" );
			return;
		}
		cop.Notify( $"Amende n°{fineId} annulée.", "info" );
		HorizonGame.Current?.AddLog( "police_amende_annulee", steamId, $"Amende n°{fineId} annulée par {MdtSignature( cop )}" );
		RoleplayPlayer.All.FirstOrDefault( x => x.SteamId == steamId )?.Notify( $"UCPD : votre amende n°{fineId} a été annulée.", "info" );
		if ( steamId != 0 )
			await MdtCitizenAsync( cop, steamId );
	}

	/// <summary>
	/// Effectifs en jeu (grade ≥ RankRoster) : changer le grade (<paramref name="fire"/> : renvoi) et
	/// l'indicatif. Jamais au-dessus de son propre grade, jamais sur un égal ou un supérieur, jamais
	/// sur soi ; l'API refait la même vérification.
	/// </summary>
	[Rpc.Host]
	public static void MdtSetRoster( ulong steamId, int grade, string callsign, bool fire )
	{
		var cop = MdtCaller( PoliceRules.RankRoster, "Gérer les effectifs" );
		if ( cop is null || steamId == 0 )
			return;

		var target = Current?.OfficerOf( steamId );
		if ( target is null )
		{
			cop.Notify( "Cet agent n'est pas inscrit à l'UCPD.", "erreur" );
			return;
		}
		if ( steamId == cop.SteamId || target.Grade >= cop.PoliceGrade )
		{
			cop.Notify( "On ne touche ni à soi-même, ni à un égal, ni à un supérieur.", "erreur" );
			return;
		}
		if ( !fire && (grade < 0 || grade >= cop.PoliceGrade || PoliceRules.Grades.All( x => x.Rank != grade )) )
		{
			cop.Notify( $"Grade refusé : au plus {PoliceRules.GradeOf( cop.PoliceGrade - 1 ).Name}.", "erreur" );
			return;
		}

		_ = MdtRosterWriteAsync( cop, steamId, target, fire ? null : grade, fire ? "" : MdtCallsignOf( callsign ) );
	}

	static async Task MdtRosterWriteAsync( RoleplayPlayer cop, ulong steamId, Officer before, int? grade, string callsign )
	{
		var response = await Backend.MdtRoster( steamId, grade, callsign, MdtSignature( cop ), cop.PoliceGrade );
		if ( !cop.IsValid() )
			return;
		if ( response?.Ok != true )
		{
			cop.Notify( response?.Error ?? "Le fichier ne répond pas : effectifs inchangés.", "erreur" );
			return;
		}

		var name = knownNames.GetValueOrDefault( steamId.ToString() ) ?? $"l'agent {before.Badge}";
		string what;
		if ( grade is null )
			what = $"{name} est renvoyé de l'UCPD.";
		else if ( grade > before.Grade )
			what = $"{name} est promu {PoliceRules.GradeOf( grade.Value ).Name}.";
		else if ( grade < before.Grade )
			what = $"{name} est rétrogradé {PoliceRules.GradeOf( grade.Value ).Name}.";
		else
			what = $"{name} : indicatif {(string.IsNullOrEmpty( callsign ) ? "retiré" : callsign)}.";
		cop.Notify( what, "info" );
		RoleplayPlayer.All.FirstOrDefault( x => x.SteamId == steamId )?.Notify( $"UCPD : {what}", "annonce" );
		HorizonGame.Current?.AddLog( "police_effectifs", steamId, $"{what} Par {MdtSignature( cop )}" );

		// Grade et indicatif d'un agent en ville (PoliceGrade, PoliceCallsign) sont écrits à la
		// synchronisation des effectifs (serverPoliceSync, PoliceService.Service.cs) : on la relance
		// tout de suite, puis on relit la liste une fois qu'elle a eu le temps de revenir.
		Current?.RequestSync();
		await GameTask.Delay( 1500 );
		if ( cop.IsValid() )
			await MdtRosterAsync( cop );
	}
}