Police/PoliceService.Service.cs

Half of the host-side police component (partial class PoliceService) for a roleplay gamemode. Pulls officers, penal code and flagged plates from the backend every 30 s and mirrors them to players as JSON, gates the police job, handles on/off duty at the locker, issues and reclaims service weapons and ammo, runs jail timers with resume/save, and pays desk fines.

Networking
namespace HorizonRP;

/// <summary>
/// Le chantier « Justice et service » côté hôte (outils/police/CONCEPTION.md, section 4) :
///
/// - <b>Synchronisation</b> avec l'API (serverPoliceSync) au démarrage, toutes les 30 s et à l'ordre
///   « police_sync » du panneau : effectifs, code pénal, véhicules signalés, publiés à tous en JSON ;
///   grade, matricule et indicatif posés sur chaque joueur. Un joueur retiré des effectifs perd le
///   métier et le service.
/// - <b>Liste blanche</b> du métier (<see cref="JobRefusal"/>, appelé par HorizonGame.TryChangeJob).
/// - <b>Service</b> : pris et rendu au vestiaire (point « police_vestiaire ») ; paie du grade en
///   service, rien hors service (<see cref="SalaryOf"/>) ; la fin du service (vestiaire, changement de
///   métier, renvoi, prison, déconnexion) reprend les armes de service et rend le véhicule de service
///   (<see cref="ReleaseServiceVehicle"/>, PoliceService.Garage.cs, chantier « Véhicules de police »).
/// - <b>Armurerie</b> (PoliceRules.Armory, selon le grade) : armes marquées « service » dans leur
///   donnée (WeaponData.Service, série « UCPD- »), munitions données avec, tout repris en fin de
///   service. Une arme de service ne se jette pas, ne se donne pas, ne se sauvegarde pas ; trouvée
///   entre les mains de quelqu'un qui n'est pas en service, elle est reprise dans la seconde.
/// - <b>Prison</b> : décompte en ligne seulement, sauvegarde régulière et à la déconnexion
///   (serverPoliceJailSave), reprise à la reconnexion (serverPoliceJail), ramené en cellule au-delà
///   de PoliceRules.JailRadius, libéré au point « police_liberation ».
///
/// Tout est appelé par HorizonGame (TickSecond, connexion, déconnexion, ordres) : aucune méthode de
/// cycle de vie n'est redéfinie ici, les autres chantiers ont leurs propres partiels du composant.
/// </summary>
public sealed partial class PoliceService
{
	/// <summary>Secondes entre deux lectures des effectifs et du code pénal.</summary>
	public const float SyncSeconds = 30f;

	/// <summary>Une peine en cours est sauvegardée toutes les minutes (et à la déconnexion).</summary>
	public const int JailSaveSeconds = 60;

	/// <summary>Le plus haut grade (le chef de la police).</summary>
	public static int MaxRank => PoliceRules.Grades.Max( x => x.Rank );

	RealTimeSince sinceSync = 1000;
	bool syncing;

	/// <summary>Hôte : au moins une lecture des effectifs a réussi (avant, on ne renvoie personne).</summary>
	public bool RosterReceived { get; private set; }

	// ------------------------------------------------------------------ synchronisation

	/// <summary>Hôte : relit effectifs et code pénal tout de suite (ordre « police_sync », tablette après une promotion).</summary>
	public void RequestSync()
	{
		if ( !Networking.IsHost || syncing )
			return;
		sinceSync = 0;
		_ = SyncAsync();
	}

	/// <summary>Hôte, chaque seconde (HorizonGame.TickSecond).</summary>
	public void TickSecond()
	{
		if ( !Networking.IsHost )
			return;

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

		foreach ( var player in RoleplayPlayer.All.Where( x => x.IsValid() && x.ProfileLoaded ).ToList() )
		{
			TickPlayer( player );
			TickJail( player );
		}

		// Les secours (PoliceService.Ems.cs) : effectifs, fin de service qui s'impose.
		TickEms();
	}

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

		syncing = true;
		try
		{
			var response = await Backend.JusticeSync();
			if ( !this.IsValid() )
				return;
			if ( response?.Ok != true )
			{
				if ( response is not null )
					Log.Warning( $"[Horizon] Police : synchronisation refusée ({response.Error})" );
				return;
			}

			if ( response.Officers is not null )
			{
				var list = response.Officers
					.Where( x => ulong.TryParse( x.SteamIdText, out _ ) )
					.Select( x => new Officer( x.SteamIdText, Math.Clamp( x.Grade, 0, MaxRank ), x.Badge ?? "", x.Callsign ?? "" ) )
					.ToList();
				var json = Json.Serialize( list );
				if ( json != OfficersJson )
					OfficersJson = json;
				RosterReceived = true;
			}

			if ( response.Penal is not null )
			{
				var list = response.Penal
					.Where( x => !string.IsNullOrWhiteSpace( x.Id ) )
					.Select( x => new PenalEntry( x.Id, x.Category ?? "Divers", x.Title ?? x.Id, x.Description ?? "", x.Class ?? "infraction", Math.Max( 0, x.Fine ), Math.Max( 0, x.JailMinutes ) ) )
					.ToList();
				var json = Json.Serialize( list );
				if ( json != PenalJson )
					PenalJson = json;
			}

			if ( response.Flags is not null )
			{
				var json = Json.Serialize( response.Flags.Where( x => !string.IsNullOrWhiteSpace( x.Plate ) ).Select( x => new PlateFlag( x.Plate, x.Reason ?? "" ) ).ToList() );
				if ( json != FlagsJson )
					FlagsJson = json;
			}

			foreach ( var player in RoleplayPlayer.All.ToList() )
				ApplyRosterTo( player );
		}
		finally
		{
			syncing = false;
		}
	}

	/// <summary>
	/// L'agent de ce joueur. Partie locale (sans API, donc sans effectifs) : le staff passe pour chef
	/// de la police, le temps des essais.
	/// </summary>
	public Officer OfficerFor( RoleplayPlayer player ) =>
		player is null ? null
		: OfficerOf( player.SteamId ) ?? (!Backend.IsOnline && player.IsStaff ? new Officer( player.SteamId.ToString(), MaxRank, "0000", "STAFF" ) : null);

	/// <summary>Hôte : grade, matricule et indicatif d'après les effectifs ; retiré des effectifs, il redevient citoyen.</summary>
	public void ApplyRosterTo( RoleplayPlayer player )
	{
		if ( !player.IsValid() || !player.ProfileLoaded )
			return;

		var officer = OfficerFor( player );
		var grade = officer?.Grade ?? -1;
		if ( player.PoliceGrade != grade )
			player.PoliceGrade = grade;
		if ( player.PoliceBadge != officer?.Badge )
			player.PoliceBadge = officer?.Badge;
		if ( player.PoliceCallsign != officer?.Callsign )
			player.PoliceCallsign = officer?.Callsign;

		if ( officer is null && player.JobId == PoliceRules.JobId && (RosterReceived || !Backend.IsOnline) )
		{
			EndService( player, "retiré des effectifs", notify: false );
			player.JobId = Jobs.DefaultId;
			player.Notify( "Vous ne figurez plus aux effectifs de l'UCPD : vous redevenez citoyen.", "annonce" );
			HorizonGame.Current?.AddLog( "police_effectifs", player.SteamId, $"{player.RpName} n'est plus aux effectifs : métier remis à citoyen" );
		}
	}

	/// <summary>
	/// La liste blanche du métier (HorizonGame.TryChangeJob) : null si ce joueur peut devenir
	/// policier, sinon la raison du refus.
	/// </summary>
	public static string JobRefusal( RoleplayPlayer player )
	{
		if ( player.Jailed )
			return "Pas de métier de police derrière les barreaux.";
		var service = Current;
		if ( service is null )
			return "Le système de police n'est pas chargé.";
		if ( Backend.IsOnline && !service.RosterReceived )
			return "Les effectifs de l'UCPD ne sont pas encore chargés : réessayez dans quelques secondes.";
		if ( service.OfficerFor( player ) is null )
			return "Policier est un métier sur liste blanche : seuls les agents inscrits aux effectifs de l'UCPD le prennent. Adressez-vous au staff.";
		return null;
	}

	/// <summary>
	/// Le salaire d'une paie : un policier (ou un secouriste de l'UCFD) touche celui de son grade en
	/// service, rien hors service ; les autres métiers, le leur. Lu par l'hôte (paie) et par le HUD
	/// (montant affiché).
	/// </summary>
	public static int SalaryOf( RoleplayPlayer player ) =>
		player is null ? 0
		: player.JobId == PoliceRules.JobId ? (player.IsCop ? PoliceRules.GradeOf( player.PoliceGrade ).Salary : 0)
		: player.JobId == EmsRules.JobId ? (player.IsEms ? EmsRules.GradeOf( player.EmsGrade ).Salary : 0)
		: player.Job.Salary;

	/// <summary>« Policier · Sergent », « EMS · Paramédic » : la ligne de la paie.</summary>
	public static string PayLabel( RoleplayPlayer player ) =>
		player.IsCop ? $"{player.Job.Name} · {PoliceRules.GradeOf( player.PoliceGrade ).Name}"
		: player.IsEms ? $"{player.Job.Name} · {EmsRules.GradeOf( player.EmsGrade ).Name}"
		: player.Job.Name;

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

	// ------------------------------------------------------------------ chaque seconde

	/// <summary>Le siège où ce joueur est assis, ou null.</summary>
	public static VehicleSeat SeatOf( RoleplayPlayer player ) => player?.GameObject.GetComponentInParent<VehicleSeat>();

	/// <summary>Hôte : fait descendre ce joueur de son siège.</summary>
	public static void Unseat( RoleplayPlayer player )
	{
		if ( SeatOf( player ) is { } seat && player.GetComponent<PlayerController>() is { } pc )
			seat.Eject( pc );
	}

	/// <summary>Hôte : qui a menotté qui (les menottes tombent si l'agent quitte son service).</summary>
	readonly Dictionary<ulong, ulong> cuffedBy = new();

	public void NoteCuffs( RoleplayPlayer citizen, RoleplayPlayer cop )
	{
		if ( cop is null )
			cuffedBy.Remove( citizen.SteamId );
		else
			cuffedBy[citizen.SteamId] = cop.SteamId;
	}

	void TickPlayer( RoleplayPlayer player )
	{
		// Plus policier (F4, ordre du panneau, renvoi) : fin du service.
		if ( player.OnDuty && (player.JobId != PoliceRules.JobId || player.PoliceGrade < 0) )
			EndService( player, "changement de métier" );

		// Une arme de service entre d'autres mains (ramassée au sol, prise dans un coffre…) : reprise.
		if ( !player.IsCop && TakeServiceItems( player ) > 0 )
			player.Notify( "Arme de service récupérée par l'UCPD.", "info" );

		// Menotté au volant : jamais (le menotté ne monte pas seul, mais on ne sait jamais).
		if ( player.Cuffed && SeatOf( player ) is { IsDriver: true } )
			Unseat( player );

		if ( player.EscortedBy != Guid.Empty )
		{
			// Un agent escorte un menotté ; un secouriste en service, un blessé (menotté ou non :
			// Ems/EmsActions.cs).
			var officer = Scene.Directory.FindByGuid( player.EscortedBy )?.GetComponent<RoleplayPlayer>();
			var byMedic = officer is not null && officer.IsEms;
			var broken = officer is null || !(officer.IsCop || byMedic) || officer.OnTheGround || officer.Cuffed || SeatOf( officer ) is not null
				|| (!player.Cuffed && !byMedic) || player.OnTheGround || SeatOf( player ) is not null
				|| officer.WorldPosition.Distance( player.WorldPosition ) > 300;
			if ( broken )
			{
				player.EscortedBy = Guid.Empty;
				officer?.Notify( $"Vous n'escortez plus {player.RpName}.", "info" );
			}
		}
	}

	// ------------------------------------------------------------------ service

	public static bool Near( RoleplayPlayer player, ServerSettings.Point point, float range ) =>
		point is not null && player.WorldPosition.Distance( point.Position ) <= range;

	/// <summary>Hôte : prise de service au vestiaire.</summary>
	public void StartService( RoleplayPlayer player )
	{
		if ( player.JobId != PoliceRules.JobId || player.PoliceGrade < 0 )
		{
			player.Notify( "Le vestiaire est réservé aux agents de l'UCPD.", "erreur" );
			return;
		}
		if ( player.OnDuty || player.Jailed || player.Cuffed || player.OnTheGround )
			return;
		if ( !Near( player, ServerSettings.PoliceLocker, PoliceRules.PointRange * 1.5f ) )
		{
			player.Notify( "La prise de service se fait au vestiaire du commissariat.", "erreur" );
			return;
		}

		player.OnDuty = true;
		PutOnUniform( player );
		var grade = PoliceRules.GradeOf( player.PoliceGrade );
		var callsign = string.IsNullOrEmpty( player.PoliceCallsign ) ? "" : $", indicatif {player.PoliceCallsign}";
		player.Notify( $"Prise de service : {grade.Name}{callsign}. Armurerie au vestiaire, menu police (F6).", "info" );
		HorizonGame.Current?.AddLog( "police_service", player.SteamId, $"{JusticeSignature( player )} prend son service" );
	}

	/// <summary>
	/// Hôte : fin de service (vestiaire, changement de métier, renvoi, prison, déconnexion). Les armes
	/// de service et leurs munitions reprises, la tenue civile remise, le véhicule de service rendu,
	/// l'escorte lâchée, les menottes qu'il avait posées retirées.
	/// </summary>
	public void EndService( RoleplayPlayer player, string reason, bool notify = true )
	{
		if ( !player.IsValid() )
			return;

		var was = player.OnDuty;
		player.OnDuty = false;
		var returned = TakeServiceItems( player );
		RestoreCivilian( player );

		foreach ( var other in RoleplayPlayer.All.Where( x => x.EscortedBy == player.GameObject.Id ).ToList() )
			other.EscortedBy = Guid.Empty;

		foreach ( var (citizenId, copId) in cuffedBy.ToList() )
		{
			if ( copId != player.SteamId )
				continue;
			cuffedBy.Remove( citizenId );
			if ( RoleplayPlayer.All.FirstOrDefault( x => x.SteamId == citizenId ) is { Cuffed: true } citizen && !citizen.Jailed )
			{
				citizen.Cuffed = false;
				citizen.Notify( "L'agent qui vous a menotté a quitté son service : vos menottes sont retirées.", "info" );
			}
		}

		if ( !was )
			return;

		ReleaseServiceVehicle( player );
		if ( notify )
			player.Notify( returned > 0 ? $"Fin de service : armement rendu ({returned} objet(s))." : "Fin de service.", "info" );
		HorizonGame.Current?.AddLog( "police_service", player.SteamId, $"{JusticeSignature( player )} quitte son service ({reason}){(returned > 0 ? $", {returned} objet(s) de service rendus" : "")}" );
	}

	// ------------------------------------------------------------------ tenue

	/// <summary>
	/// Hôte : la tenue de service de son grade (PoliceUniform), passée à la prise de service par-dessus
	/// son corps et sa tête ; les vêtements civils sont gardés à part (CivilianAppearance, seuls
	/// enregistrés en base) et rendus à la fin du service.
	/// </summary>
	public void PutOnUniform( RoleplayPlayer player )
	{
		// Coupé par PoliceUniform.Enabled (il l'a été du 29 au 30/10/2026, faute d'une tenue au niveau).
		if ( !PoliceUniform.Enabled )
			return;
		if ( player.CivilianAppearance is not null )
			return;
		var uniform = PoliceUniform.For( player.AppearanceJson, player.PoliceGrade );
		if ( uniform is null )
			return;
		player.CivilianAppearance = player.AppearanceJson;
		player.AppearanceJson = uniform;
	}

	/// <summary>Hôte : en service, l'agent retire sa tenue (inspecteur en civil) ou la remet.</summary>
	public void SetUniform( RoleplayPlayer player, bool on )
	{
		if ( !player.OnDuty )
			return;
		if ( on )
			PutOnUniform( player );
		else
			RestoreCivilian( player );
	}

	public void RestoreCivilian( RoleplayPlayer player )
	{
		if ( player.CivilianAppearance is not { } civilian )
			return;
		player.AppearanceJson = civilian;
		player.CivilianAppearance = null;
	}

	// ------------------------------------------------------------------ armurerie

	/// <summary>Une case qui tient une arme de service.</summary>
	public static bool IsServiceSlot( InventorySlot slot ) =>
		slot is not null && WeaponItems.WeaponOf( slot.Item ) is not null && WeaponData.Read( slot.Data )?.Service == true;

	/// <summary>Hôte : les munitions données par l'armurerie, par joueur, reprises avec les armes.</summary>
	readonly Dictionary<ulong, Dictionary<string, int>> issuedAmmo = new();

	/// <summary>Hôte : une arme de l'armurerie, prise au vestiaire.</summary>
	public void IssueWeapon( RoleplayPlayer player, string weaponId )
	{
		var item = PoliceRules.Armory.FirstOrDefault( x => x.WeaponId == weaponId );
		if ( item is null || !player.IsCop )
			return;
		if ( !Near( player, ServerSettings.PoliceLocker, PoliceRules.PointRange * 1.5f ) )
		{
			player.Notify( "L'armurerie est au vestiaire du commissariat.", "erreur" );
			return;
		}
		if ( player.PoliceGrade < item.MinRank )
		{
			player.Notify( $"{item.Label} : grade de {PoliceRules.GradeOf( item.MinRank ).Name} au moins.", "erreur" );
			return;
		}
		var itemId = WeaponItems.WeaponItemId( weaponId );
		if ( player.Inventory.Any( x => x.Item == itemId && IsServiceSlot( x ) ) )
		{
			player.Notify( $"Vous avez déjà votre {item.Label}.", "info" );
			return;
		}
		if ( WeaponSystem.Current?.Get( weaponId ) is not { Active: true } || Items.Get( itemId ) is not { } definition )
		{
			player.Notify( $"{item.Label} : arme absente du catalogue des armes.", "erreur" );
			return;
		}

		var data = new WeaponData { Serial = WeaponData.NewServiceSerial(), Service = true };
		if ( player.AddItem( definition, 1, data.Write() ) == 0 )
		{
			player.Notify( "Plus de place dans vos poches.", "erreur" );
			return;
		}

		var extra = "";
		if ( !string.IsNullOrEmpty( item.Ammo ) && item.AmmoCount > 0 && Items.Get( item.Ammo ) is { } ammo )
		{
			var given = player.AddItem( ammo, item.AmmoCount );
			if ( given > 0 )
			{
				var mine = issuedAmmo.GetValueOrDefault( player.SteamId ) ?? new();
				mine[item.Ammo] = mine.GetValueOrDefault( item.Ammo ) + given;
				issuedAmmo[player.SteamId] = mine;
				extra = $" et {given} × {ammo.Name}";
			}
		}

		player.Notify( $"Armurerie : {item.Label}{extra} (n° {data.Serial}).", "info" );
		HorizonGame.Current?.AddLog( "police_armurerie", player.SteamId, $"{JusticeSignature( player )} prend {item.Label} ({data.Serial}){extra}" );
	}

	/// <summary>Hôte : rend tout l'armement au vestiaire (sans quitter le service).</summary>
	public void ReturnWeapons( RoleplayPlayer player )
	{
		var count = TakeServiceItems( player );
		player.Notify( count > 0 ? $"Armement rendu ({count} objet(s))." : "Vous n'avez rien à rendre.", "info" );
		if ( count > 0 )
			HorizonGame.Current?.AddLog( "police_armurerie", player.SteamId, $"{JusticeSignature( player )} rend son armement ({count} objet(s))" );
	}

	/// <summary>Hôte : reprend les armes de service et les munitions données. Rend le nombre d'objets repris.</summary>
	public int TakeServiceItems( RoleplayPlayer player )
	{
		var taken = 0;
		foreach ( var slot in player.Inventory.Where( IsServiceSlot ).ToList() )
		{
			WeaponSystem.Current?.HolsterIfActive( player, slot );
			taken += slot.Count;
			player.RemoveFrom( slot, slot.Count );
		}

		if ( issuedAmmo.Remove( player.SteamId, out var ammo ) )
		{
			foreach ( var (ammoId, count) in ammo )
			{
				var left = count;
				foreach ( var slot in player.Inventory.Where( x => x.Item == ammoId ).OrderByDescending( x => x.Slot ).ToList() )
				{
					var take = Math.Min( left, slot.Count );
					player.RemoveFrom( slot, take );
					left -= take;
					taken += take;
					if ( left == 0 )
						break;
				}
			}
		}
		return taken;
	}

	// ------------------------------------------------------------------ prison

	readonly Dictionary<ulong, RealTimeSince> broughtBack = new();

	/// <summary>Hôte : écroue ce joueur pour <paramref name="seconds"/> secondes de jeu en ligne (la ligne hz_jail est déjà écrite par l'API).</summary>
	public void Imprison( RoleplayPlayer player, int seconds, int totalSeconds = 0 )
	{
		player.EscortedBy = Guid.Empty;
		player.Cuffed = false;
		NoteCuffs( player, null );
		if ( player.OnDuty || player.JobId == PoliceRules.JobId )
		{
			EndService( player, "écroué", notify: false );
			player.JobId = Jobs.DefaultId;
		}
		WeaponSystem.Current?.Holster( player );
		if ( Cash.IsHeld( player ) )
			player.HeldKey = null;
		Unseat( player );
		player.JailSecondsTotal = Math.Max( seconds, totalSeconds );
		player.JailSecondsLeft = seconds;
		if ( ServerSettings.PolicePrison is { } prison )
			player.TeleportTo( prison.Position );
		broughtBack[player.SteamId] = 0;
	}

	void TickJail( RoleplayPlayer player )
	{
		if ( !player.Jailed || player.NeedsCharacter )
			return;

		player.JailSecondsLeft--;
		if ( player.JailSecondsLeft <= 0 )
		{
			Release( player, "peine purgée", null );
			return;
		}

		// En prison : ni métier de police, ni arme, ni véhicule, ni menottes.
		if ( player.OnDuty || player.JobId == PoliceRules.JobId )
		{
			EndService( player, "en prison", notify: false );
			player.JobId = Jobs.DefaultId;
		}
		WeaponSystem.Current?.Holster( player );
		if ( player.Cuffed )
			player.Cuffed = false;
		if ( SeatOf( player ) is not null )
			Unseat( player );

		// Sorti de l'enceinte (évadé, réanimé à l'hôpital, réapparu) : ramené en cellule.
		if ( ServerSettings.PolicePrison is { } prison && !player.OnTheGround
			&& player.WorldPosition.Distance( prison.Position ) > PoliceRules.JailRadius
			&& (!broughtBack.TryGetValue( player.SteamId, out var since ) || since > 3f) )
		{
			broughtBack[player.SteamId] = 0;
			player.TeleportTo( prison.Position );
			player.Notify( "Vous êtes ramené en cellule : la peine continue.", "erreur" );
			HorizonGame.Current?.AddLog( "police_prison", player.SteamId, $"{player.RpName} ramené en cellule (hors de l'enceinte)" );
		}

		if ( player.JailSecondsLeft % JailSaveSeconds == 0 )
			_ = Backend.JusticeJailSave( player.SteamId, player.JailSecondsLeft );
	}

	/// <summary>Hôte : l'ordre « liberer » du panneau (ou le staff).</summary>
	public (bool, string) ReleaseFromJail( RoleplayPlayer player, string by )
	{
		if ( !player.Jailed )
			return (false, "Le joueur n'est pas en prison");
		Release( player, $"libéré par {by}", by );
		return (true, $"{player.RpName} libéré");
	}

	void Release( RoleplayPlayer player, string why, string by )
	{
		player.JailSecondsLeft = 0;
		player.JailSecondsTotal = 0;
		_ = Backend.JusticeJailSave( player.SteamId, 0 );
		if ( ServerSettings.PoliceRelease is { } exit )
			player.TeleportTo( exit.Position );
		else if ( HorizonGame.Current is { } game )
			player.TeleportTo( game.SpawnPosition() );
		player.Notify( by is null ? "Peine purgée : vous êtes libre." : "Vous êtes libéré de prison.", "annonce" );
		HorizonGame.Current?.AddLog( "police_liberation", player.SteamId, $"{player.RpName} sort de prison : {why}" );
	}

	// ------------------------------------------------------------------ connexions

	/// <summary>Hôte, profil chargé (HorizonGame.LoadProfileAsync) : effectifs, armes de service oubliées, peine en cours.</summary>
	public async Task OnProfileLoaded( RoleplayPlayer player )
	{
		// Une arme de service sauvegardée par un ancien code ou un arrêt brutal ne survit pas à la reconnexion.
		TakeServiceItems( player );
		ApplyRosterTo( player );
		ApplyEmsRosterTo( player );

		if ( !Backend.IsOnline )
			return;
		var response = await Backend.JusticeJailLoad( player.SteamId );
		if ( !player.IsValid() || response?.Ok != true || response.Jail is not { SecondsLeft: > 0 } jail )
			return;

		player.JailSecondsTotal = Math.Max( jail.SecondsTotal, jail.SecondsLeft );
		player.JailSecondsLeft = jail.SecondsLeft;
		if ( player.JobId == PoliceRules.JobId )
			player.JobId = Jobs.DefaultId;
		if ( ServerSettings.PolicePrison is { } prison && !player.NeedsCharacter )
			player.TeleportTo( prison.Position );
		broughtBack[player.SteamId] = 0;
		player.Notify( $"Votre peine reprend : {FormatJail( jail.SecondsLeft )} de prison.", "erreur" );
	}

	/// <summary>Hôte, avant la sauvegarde d'un joueur qui part : fin de service, escorte, peine sauvegardée.</summary>
	public void OnPlayerLeaving( RoleplayPlayer player )
	{
		EndService( player, "déconnexion", notify: false );
		EndEmsService( player, "déconnexion", notify: false );
		if ( player.Cuffed )
			HorizonGame.Current?.AddLog( "police_deconnexion", player.SteamId, $"{player.RpName} s'est déconnecté menotté" );
		NoteCuffs( player, null );
		if ( player.Jailed )
			_ = Backend.JusticeJailSave( player.SteamId, player.JailSecondsLeft );
		broughtBack.Remove( player.SteamId );
	}

	/// <summary>Hôte, à l'arrêt du serveur : toutes les peines en cours.</summary>
	public async Task SaveAllJail()
	{
		foreach ( var player in RoleplayPlayer.All.Where( x => x.Jailed ).ToList() )
			await Backend.JusticeJailSave( player.SteamId, player.JailSecondsLeft );
	}

	/// <summary>« 12 min 05 s ».</summary>
	public static string FormatJail( int seconds ) =>
		seconds >= 60 ? $"{seconds / 60} min {seconds % 60:00} s" : $"{seconds} s";

	// ------------------------------------------------------------------ accueil (amendes)

	/// <summary>Hôte : les amendes impayées de ce joueur, envoyées à sa machine (accueil du commissariat).</summary>
	public async Task SendDeskFines( RoleplayPlayer player )
	{
		if ( !Backend.IsOnline )
		{
			player.PoliceDeskFines( "[]", "Partie locale : les amendes sont en base, rien à payer ici." );
			return;
		}
		var response = await Backend.JusticeFines( player.SteamId );
		if ( !player.IsValid() )
			return;
		if ( response?.Ok != true )
		{
			player.PoliceDeskFines( "[]", response?.Error ?? "Le fichier ne répond pas : réessayez dans un instant." );
			return;
		}
		var unpaid = (response.Fines ?? new()).Where( x => x.Status == "impayee" ).ToList();
		player.PoliceDeskFines( Json.Serialize( unpaid ), null );
	}

	/// <summary>Hôte : paie une amende depuis HZ BANK, à l'accueil.</summary>
	public async Task PayDeskFine( RoleplayPlayer player, long fineId )
	{
		if ( !Backend.IsOnline )
			return;
		// Le solde du jeu fait foi : l'API débite celui-là et rend le nouveau.
		var response = await Backend.JusticeFinePay( player.SteamId, fineId, player.Bank );
		if ( !player.IsValid() )
			return;
		if ( response?.Ok != true )
		{
			player.Notify( response?.Error ?? "Le paiement n'a pas abouti : réessayez dans un instant.", "erreur" );
			return;
		}
		var paid = player.Bank - response.Bank;
		player.Bank = Math.Max( 0, response.Bank );
		player.Notify( paid > 0 ? $"Amende payée : -{Format.Money( paid )} (HZ BANK)." : "Amende payée.", "argent" );
		HorizonGame.Current?.AddLog( "police_amende_payee", player.SteamId, $"{player.RpName} paie l'amende n°{fineId} à l'accueil du commissariat" );
		await SendDeskFines( player );
		PhoneSystem.Current?.RefreshInvoices( player );
	}
}