Ems/EmsActions.cs

Static host-side RPC handler for the EMS (paramedic) job. Validates the caller is an on-duty medic of the right rank and within reach, then runs diagnose, heal, revive, stabilize, escort, vehicle loading, billing, roster, duty, locker/kit and hospital desk requests, writing audit log entries.

Networking
namespace HorizonRP;

/// <summary>
/// Ce que le menu des secours (F6 en service, vue « Secours » de UI/PoliceMenu.razor), le vestiaire
/// et l'accueil de l'hôpital demandent à l'hôte, sur le modèle exact de Police/PoliceActions.cs.
/// Chaque demande est revérifiée ici : l'appelant est un secouriste en service
/// (<see cref="RoleplayPlayer.IsEms"/>), en état d'agir, du grade voulu (EmsRules.Rank*), à portée
/// du patient (~2,8 m) ; le client ne désigne qu'un objet et des ids de tarifs, jamais un montant
/// (l'API le recalcule), ni une quantité de soins. Tout est journalisé et notifié aux deux parties.
///
/// - <b>Diagnostiquer</b> : vie, faim, conscience et temps qui reste avant la mort, lésions.
/// - <b>Soigner</b> un conscient : une trousse (sinon un bandage) du sac, la vie remonte peu à peu
///   (EmsRules.HealSeconds) tant que le secouriste reste près de lui.
/// - <b>Réanimer</b> un inconscient : la réanimation existante (HorizonGame.Revive), réservée aux
///   secouristes en service ; aussi par E maintenu sur le corps (RoleplayPlayer.Death.cs).
/// - <b>Stabiliser</b> un inconscient : un bandage, et EmsRules.StabilizeSeconds de plus avant la
///   mort (deux fois au plus par chute).
/// - <b>Escorter</b> un blessé debout, le <b>mettre dans un véhicule</b> (un inconscient aussi :
///   installé à l'arrière, il ne descend pas seul), l'en <b>sortir</b> : les mécanismes de la police.
/// - <b>Facture de soins</b> (EmsRules.Tariffs), payable dans l'appli Factures du téléphone ou à l'accueil.
/// </summary>
public static class EmsActions
{
	static RoleplayPlayer Caller() =>
		RoleplayPlayer.All.FirstOrDefault( x => Rpc.Caller is not null && x.Network.OwnerId == Rpc.Caller.Id && x.ProfileLoaded );

	/// <summary>Le secouriste en service qui appelle, en état d'agir et du grade voulu ; sinon null (refus notifié).</summary>
	static RoleplayPlayer Medic( int minRank = 0, string what = null )
	{
		var player = Caller();
		if ( player is null )
			return null;
		if ( !player.IsEms )
		{
			player.Notify( $"Réservé aux secouristes de l'{EmsRules.Short} en service.", "erreur" );
			return null;
		}
		if ( player.OnTheGround || player.Cuffed || player.Jailed || player.NeedsCharacter )
			return null;
		if ( player.EmsGrade < minRank )
		{
			player.Notify( $"{what ?? "Cette action"} : grade de {EmsRules.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 patient visé, à portée ; sinon null (refus notifié). Un peu plus loin s'il est assis dans un véhicule.</summary>
	static RoleplayPlayer Patient( RoleplayPlayer medic, Guid targetId )
	{
		var target = Game.ActiveScene?.Directory.FindByGuid( targetId )?.GetComponent<RoleplayPlayer>();
		if ( target is null || target == medic || !target.ProfileLoaded || target.NeedsCharacter )
			return null;
		if ( At( target ).Distance( medic.WorldPosition ) > (target.Seated ? PoliceActions.Reach * 1.6f : PoliceActions.Reach) )
		{
			medic.Notify( "Approchez-vous du patient.", "erreur" );
			return null;
		}
		return target;
	}

	static bool StillThere( RoleplayPlayer medic, RoleplayPlayer target ) =>
		medic.IsValid() && target.IsValid() && medic.IsEms && !medic.OnTheGround
		&& At( target ).Distance( medic.WorldPosition ) <= (target.Seated ? PoliceActions.Reach * 1.8f : PoliceActions.Reach * 1.3f);

	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>Un soin à la fois par secouriste (un double clic ne lance pas deux trousses).</summary>
	static readonly HashSet<ulong> busy = new();

	/// <summary>Le premier objet de ce type dans les poches, ou null.</summary>
	static InventorySlot SlotOf( RoleplayPlayer player, string itemId ) =>
		player.Inventory.Where( x => x.Item == itemId && x.Count > 0 ).OrderBy( x => x.Slot ).FirstOrDefault();

	/// <summary>Consomme un objet du sac (et le retire de la dotation reprise en fin de service).</summary>
	static void Consume( RoleplayPlayer medic, InventorySlot slot )
	{
		var itemId = slot.Item;
		if ( medic.HeldKey == itemId )
			medic.HeldKey = null;
		medic.RemoveFrom( slot, 1 );
		PoliceService.Current?.NoteKitUsed( medic, itemId );
	}

	// ------------------------------------------------------------------ examen et soins

	/// <summary>Diagnostiquer : l'état du patient s'affiche chez le secouriste (vue « Secours »).</summary>
	[Rpc.Host]
	public static void RequestDiagnose( Guid targetId )
	{
		var medic = Medic( EmsRules.RankCare );
		if ( medic is null || Patient( medic, targetId ) is not { } target )
			return;
		medic.EmsDiagnosis( target.GameObject.Id, Diagnosis( target ) );
		if ( !target.Downed )
			target.Notify( "Un secouriste vous examine.", "info" );
		medic.PoliceKnow( target.SteamId );
		Journal( "ems_soins", target, $"{PoliceService.EmsSignature( medic )} examine {Who( target )}" );
	}

	static string Diagnosis( RoleplayPlayer p )
	{
		var lines = new List<string>();
		if ( p.Downed )
		{
			lines.Add( $"Inconscient · décès dans {PoliceService.FormatJail( p.DownedLeft )} sans réanimation" );
			var done = PoliceService.Current?.StabilizedCount( p ) ?? 0;
			lines.Add( $"Pouls filant, ne répond pas · stabilisé {done}/{EmsRules.StabilizeMax}" );
		}
		else
			lines.Add( p.Health < 30 ? "Conscient, en détresse" : "Conscient, orienté" );
		lines.Add( $"Vie : {MathF.Round( p.Health ):0} %" );
		lines.Add( $"Faim : {MathF.Round( p.Hunger ):0} %{(p.Hunger < 15 ? " (dénutrition)" : "")}" );
		lines.Add( $"Lésions : {Injury( p )}" );
		return string.Join( "\n", lines );
	}

	/// <summary>Ce que voit un secouriste : la nature des blessures, jamais l'auteur ni l'arme (le journal les a).</summary>
	static string Injury( RoleplayPlayer p )
	{
		var cause = (p.DownCause ?? "").ToLowerInvariant();
		if ( p.Downed )
		{
			if ( cause.StartsWith( "abattu" ) )
				return cause.Contains( "tête" ) ? "plaie par balle à la tête" : "plaies par balle";
			if ( cause.StartsWith( "renversé" ) )
				return "polytraumatisme (renversé par un véhicule)";
			if ( cause.StartsWith( "faim" ) )
				return "dénutrition sévère";
			if ( cause.Contains( "explos" ) || cause.Contains( "grenade" ) || cause.Contains( "feu" ) || cause.Contains( "brûl" ) )
				return "brûlures et blessures par explosion";
			return "traumatisme, cause indéterminée";
		}
		return p.Health < 40 ? "blessures graves" : p.Health < 85 ? "blessures légères" : "aucune lésion visible";
	}

	/// <summary>Soigner un patient conscient : une trousse (sinon un bandage) du sac, la vie remonte peu à peu.</summary>
	[Rpc.Host]
	public static void RequestHeal( Guid targetId )
	{
		var medic = Medic( EmsRules.RankCare, "Soigner" );
		if ( medic is null || Patient( medic, targetId ) is not { } target )
			return;
		if ( target.Downed )
		{
			medic.Notify( "Le patient est inconscient : réanimez-le (ou stabilisez-le d'abord).", "erreur" );
			return;
		}
		if ( target.Health >= 100 )
		{
			medic.Notify( "Le patient n'a besoin d'aucun soin.", "info" );
			return;
		}
		var slot = SlotOf( medic, "trousse" ) ?? SlotOf( medic, "bandage" );
		if ( slot is null )
		{
			medic.Notify( "Plus de trousse ni de bandage : réapprovisionnez-vous au vestiaire de l'hôpital.", "erreur" );
			return;
		}
		if ( !busy.Add( medic.SteamId ) )
			return;
		var trousse = slot.Item == "trousse";
		var name = slot.Definition?.Name ?? slot.Item;
		Consume( medic, slot );
		_ = HealAsync( medic, target, trousse ? EmsRules.HealTrousse : EmsRules.HealBandage, name );
	}

	static async Task HealAsync( RoleplayPlayer medic, RoleplayPlayer target, float amount, string itemName )
	{
		try
		{
			medic.Notify( $"Soins en cours ({itemName}, {EmsRules.HealSeconds:0} s) : restez près du patient.", "info" );
			target.Notify( "Un secouriste vous soigne.", "info" );
			const int Steps = 12;
			var given = 0f;
			for ( var i = 0; i < Steps; i++ )
			{
				await GameTask.DelaySeconds( EmsRules.HealSeconds / Steps );
				if ( !StillThere( medic, target ) || target.Downed )
				{
					if ( medic.IsValid() )
						medic.Notify( "Soins interrompus.", "erreur" );
					break;
				}
				var step = MathF.Min( amount / Steps, 100 - target.Health );
				target.Health = MathF.Min( 100, target.Health + step );
				given += step;
				if ( target.Health >= 100 )
					break;
			}
			if ( !target.IsValid() || !medic.IsValid() || given <= 0 )
				return;
			medic.Notify( $"Patient soigné : +{given:0} points de vie ({target.Health:0} %).", "info" );
			target.Notify( $"Vous êtes soigné : {target.Health:0} % de vie.", "info" );
			Journal( "ems_soins", target, $"{PoliceService.EmsSignature( medic )} soigne {Who( target )} ({itemName}, +{given:0} PV)" );
		}
		finally
		{
			busy.Remove( medic.SteamId );
		}
	}

	/// <summary>Réanimer un inconscient (la réanimation existante, réservée aux secouristes en service).</summary>
	[Rpc.Host]
	public static void RequestRevive( Guid targetId )
	{
		var medic = Medic( EmsRules.RankRevive, "Réanimer" );
		if ( medic is null || Patient( medic, targetId ) is not { } target )
			return;
		if ( !target.Downed )
		{
			medic.Notify( "Le patient est conscient : soignez-le.", "info" );
			return;
		}
		if ( !busy.Add( medic.SteamId ) )
			return;
		_ = ReviveAsync( medic, target );
	}

	static async Task ReviveAsync( RoleplayPlayer medic, RoleplayPlayer target )
	{
		try
		{
			var seconds = MathF.Max( 1f, ServerSettings.ReviveSeconds );
			medic.Notify( $"Réanimation en cours ({seconds:0} s) : massage cardiaque, restez près du patient.", "info" );
			await GameTask.DelaySeconds( seconds );
			if ( !StillThere( medic, target ) || !target.Downed )
			{
				if ( medic.IsValid() && target.IsValid() && target.Downed )
					medic.Notify( "Réanimation interrompue.", "erreur" );
				return;
			}
			Journal( "ems_soins", target, $"{PoliceService.EmsSignature( medic )} réanime {Who( target )}" );
			HorizonGame.Current?.Revive( target, medic );
		}
		finally
		{
			busy.Remove( medic.SteamId );
		}
	}

	/// <summary>Stabiliser un inconscient : un bandage, et du temps en plus avant la mort.</summary>
	[Rpc.Host]
	public static void RequestStabilize( Guid targetId )
	{
		var medic = Medic( EmsRules.RankCare, "Stabiliser" );
		if ( medic is null || Patient( medic, targetId ) is not { } target || PoliceService.Current is not { } service )
			return;
		if ( !target.Downed )
		{
			medic.Notify( "On ne stabilise qu'un patient inconscient.", "info" );
			return;
		}
		if ( service.StabilizedCount( target ) >= EmsRules.StabilizeMax )
		{
			medic.Notify( "Le patient ne peut plus être stabilisé : il faut le réanimer.", "erreur" );
			return;
		}
		var slot = SlotOf( medic, "bandage" ) ?? SlotOf( medic, "trousse" );
		if ( slot is null )
		{
			medic.Notify( "Il faut un bandage (ou une trousse) pour stabiliser : vestiaire de l'hôpital.", "erreur" );
			return;
		}
		if ( !busy.Add( medic.SteamId ) )
			return;
		var name = slot.Definition?.Name ?? slot.Item;
		Consume( medic, slot );
		_ = StabilizeAsync( medic, target, service, name );
	}

	static async Task StabilizeAsync( RoleplayPlayer medic, RoleplayPlayer target, PoliceService service, string itemName )
	{
		try
		{
			medic.Notify( $"Stabilisation ({EmsRules.StabilizeDuration:0} s) : compression, position latérale…", "info" );
			await GameTask.DelaySeconds( EmsRules.StabilizeDuration );
			if ( !StillThere( medic, target ) || !target.Downed )
			{
				if ( medic.IsValid() )
					medic.Notify( "Stabilisation interrompue.", "erreur" );
				return;
			}
			target.DownedLeft += EmsRules.StabilizeSeconds;
			service.NoteStabilized( target );
			medic.Notify( $"Patient stabilisé : {PoliceService.FormatJail( target.DownedLeft )} avant le décès.", "info" );
			Journal( "ems_soins", target, $"{PoliceService.EmsSignature( medic )} stabilise {Who( target )} ({itemName}, +{EmsRules.StabilizeSeconds} s)" );
		}
		finally
		{
			busy.Remove( medic.SteamId );
		}
	}

	// ------------------------------------------------------------------ escorte et véhicule

	/// <summary>Escorter un blessé debout (il suit le secouriste, posé devant lui), ou le lâcher.</summary>
	[Rpc.Host]
	public static void RequestEscort( Guid targetId, bool escort )
	{
		var medic = Medic( EmsRules.RankCare );
		if ( medic is null || Patient( medic, targetId ) is not { } target )
			return;
		if ( !escort )
		{
			if ( target.EscortedBy != medic.GameObject.Id )
				return;
			target.EscortedBy = Guid.Empty;
			medic.Notify( "Vous lâchez le patient.", "info" );
			return;
		}
		if ( target.OnTheGround || target.Seated || medic.Seated )
		{
			medic.Notify( target.Downed ? "Un inconscient ne s'escorte pas : installez-le dans un véhicule." : "Le patient doit être debout.", "erreur" );
			return;
		}
		if ( target.EscortedBy != Guid.Empty )
		{
			medic.Notify( "Quelqu'un escorte déjà ce patient.", "erreur" );
			return;
		}
		foreach ( var other in RoleplayPlayer.All.Where( x => x.EscortedBy == medic.GameObject.Id && x != target ).ToList() )
			other.EscortedBy = Guid.Empty;
		target.EscortedBy = medic.GameObject.Id;
		target.Notify( "Un secouriste vous soutient et vous accompagne.", "info" );
		medic.Notify( "Vous escortez le patient.", "info" );
		Journal( "ems_soins", target, $"{PoliceService.EmsSignature( medic )} escorte {Who( target )}" );
	}

	/// <summary>Installer le patient (debout, escorté ou inconscient) à l'arrière du véhicule le plus proche.</summary>
	[Rpc.Host]
	public static void RequestPutInVehicle( Guid targetId )
	{
		var medic = Medic( EmsRules.RankCare );
		if ( medic is null || Patient( medic, targetId ) is not { } target )
			return;
		if ( target.Seated || target.Knocked )
			return;
		if ( target.EscortedBy != Guid.Empty && target.EscortedBy != medic.GameObject.Id )
		{
			medic.Notify( "Ce patient est escorté par quelqu'un d'autre.", "erreur" );
			return;
		}
		if ( PoliceActions.NearestVehicle( medic.WorldPosition, PoliceActions.VehicleRange ) is not { } vehicle )
		{
			medic.Notify( "Aucun véhicule à côté de vous.", "erreur" );
			return;
		}
		if ( vehicle.Locked || !vehicle.Usable )
		{
			medic.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 )
		{
			medic.Notify( "Plus de place dans ce véhicule.", "erreur" );
			return;
		}
		target.EscortedBy = Guid.Empty;
		// Comme la police (BaseChair.EnterChair) : le siège est pris chez le joueur, qui simule son
		// personnage. Un inconscient y perd son ragdoll (RoleplayPlayer.Death.cs) et n'en descend
		// pas seul (VehicleSeat.CanLeave).
		using ( Rpc.FilterInclude( owner ) )
			seat.Sit( pc );
		if ( !target.Downed )
			target.Notify( "On vous installe dans le véhicule.", "info" );
		medic.Notify( $"Patient installé dans {vehicle.Plate}.", "info" );
		Journal( "ems_soins", target, $"{PoliceService.EmsSignature( medic )} installe {Who( target )}{(target.Downed ? " (inconscient)" : "")} dans {vehicle.Plate}" );
	}

	/// <summary>Faire descendre un patient d'un véhicule (un inconscient y retombe au sol, à la portière).</summary>
	[Rpc.Host]
	public static void RequestTakeOutOfVehicle( Guid targetId )
	{
		var medic = Medic( EmsRules.RankCare );
		var target = Game.ActiveScene?.Directory.FindByGuid( targetId )?.GetComponent<RoleplayPlayer>();
		if ( medic is null || target is null || target == medic || PoliceService.SeatOf( target ) is not { } seat )
			return;
		if ( PoliceActions.BodyDistance( seat.Vehicle, medic.WorldPosition ) > PoliceActions.VehicleRange )
		{
			medic.Notify( "Approchez-vous du véhicule.", "erreur" );
			return;
		}
		if ( seat.Vehicle.Locked )
		{
			medic.Notify( "Le véhicule est verrouillé.", "erreur" );
			return;
		}
		PoliceService.Unseat( target );
		if ( !target.Downed )
			target.Notify( "On vous fait descendre du véhicule.", "info" );
		medic.Notify( "Patient sorti du véhicule.", "info" );
		Journal( "ems_soins", target, $"{PoliceService.EmsSignature( medic )} fait descendre {Who( target )} de {seat.Vehicle.Plate}" );
	}

	// ------------------------------------------------------------------ facture

	/// <summary>Une facture à la fois par secouriste (un double clic n'en dresse pas deux).</summary>
	static readonly HashSet<ulong> billing = new();

	/// <summary>Établir une facture de soins : ids de EmsRules.Tariffs séparés par des virgules (un même soin peut revenir).</summary>
	[Rpc.Host]
	public static void RequestBill( Guid targetId, string tariffIds, string notes )
	{
		var medic = Medic( EmsRules.RankBill, "Facturer des soins" );
		if ( medic is null || Patient( medic, targetId ) is not { } target )
			return;
		var items = new List<string>();
		foreach ( var id in (tariffIds ?? "").Split( ',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries ) )
		{
			if ( EmsRules.TariffOf( id ) is null || id == EmsRules.DeskTariff )
			{
				medic.Notify( "Soin inconnu sur la facture.", "erreur" );
				return;
			}
			items.Add( id );
		}
		if ( items.Count == 0 || items.Count > EmsRules.BillLinesMax )
		{
			medic.Notify( $"Choisissez de 1 à {EmsRules.BillLinesMax} soins.", "erreur" );
			return;
		}
		if ( !Backend.IsOnline )
		{
			medic.Notify( "Partie locale : les factures sont en base, facture impossible ici.", "erreur" );
			return;
		}
		if ( !billing.Add( medic.SteamId ) )
			return;
		notes = (notes ?? "").Trim();
		if ( notes.Length > PoliceActions.NotesMax )
			notes = notes[..PoliceActions.NotesMax];
		_ = BillAsync( medic, target, items, notes );
	}

	static async Task BillAsync( RoleplayPlayer medic, RoleplayPlayer target, List<string> items, string notes )
	{
		try
		{
			var response = await Backend.EmsBillCreate( target.SteamId, items, notes, PoliceService.EmsSignature( medic ), medic.SteamId );
			if ( !medic.IsValid() )
				return;
			if ( response?.Ok != true )
			{
				medic.Notify( response?.Error ?? "Le secrétariat ne répond pas : facture non enregistrée.", "erreur" );
				return;
			}
			var total = response.Amount > 0 ? response.Amount : items.Sum( x => EmsRules.TariffOf( x )?.Price ?? 0 );
			if ( target.IsValid() )
			{
				target.Notify( $"Facture de soins de {Format.Money( total )} ({response.Title}). Payez-la sur votre téléphone (appli Factures) ou à l'accueil de l'hôpital.", "argent" );
				PhoneSystem.Current?.RefreshInvoices( target );
			}
			medic.Notify( $"Facture de {Format.Money( total )} établie (n° {response.BillId}).", "info" );
			Journal( "ems_facture", target, $"{PoliceService.EmsSignature( medic )} facture {Format.Money( total )} de soins à {Who( target )} : {string.Join( ", ", items )}" );
		}
		finally
		{
			billing.Remove( medic.SteamId );
		}
	}

	// ------------------------------------------------------------------ effectifs (gradé)

	/// <summary>
	/// Un gradé (EmsRules.RankRoster) agit sur le joueur visé : « recruter » (stagiaire),
	/// « promouvoir », « retrograder », « renvoyer ». L'API refait les vérifications.
	/// </summary>
	[Rpc.Host]
	public static void RequestRoster( Guid targetId, string action )
	{
		var chief = Medic( EmsRules.RankRoster, "Gérer les effectifs" );
		if ( chief is null || Patient( chief, targetId ) is not { } target || PoliceService.Current is not { } service )
			return;
		if ( !Backend.IsOnline )
		{
			chief.Notify( "Partie locale : les effectifs sont en base.", "erreur" );
			return;
		}
		var current = service.MedicOf( target.SteamId );
		if ( current is not null && current.Grade >= chief.EmsGrade )
		{
			chief.Notify( "On ne touche ni à un égal, ni à un supérieur.", "erreur" );
			return;
		}
		int? grade = action switch
		{
			"recruter" when current is null => 0,
			"promouvoir" when current is not null && current.Grade + 1 < chief.EmsGrade => current.Grade + 1,
			"retrograder" when current is not null && current.Grade > 0 => current.Grade - 1,
			_ => -1,
		};
		if ( action == "renvoyer" && current is not null )
			grade = null;
		else if ( grade == -1 )
		{
			chief.Notify( "Action impossible sur ce joueur.", "erreur" );
			return;
		}
		_ = service.EmsRosterWrite( chief, target, grade );
	}

	// ------------------------------------------------------------------ vestiaire et accueil

	static bool AtLocker( RoleplayPlayer player )
	{
		if ( PoliceService.Near( player, ServerSettings.EmsLocker, EmsRules.PointRange * 1.5f ) )
			return true;
		player.Notify( "Rendez-vous au vestiaire de l'hôpital.", "erreur" );
		return false;
	}

	/// <summary>Prendre ou finir son service (au vestiaire de l'hôpital).</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.StartEmsService( player );
		else if ( player.EmsOnDuty )
			service.EndEmsService( player, "fin de service au vestiaire" );
	}

	/// <summary>Le matériel du vestiaire (EmsRules.Kit), complété jusqu'à la dotation.</summary>
	[Rpc.Host]
	public static void RequestKit()
	{
		var medic = Medic();
		if ( medic is null || !AtLocker( medic ) )
			return;
		PoliceService.Current?.IssueKit( medic );
	}

	/// <summary>Rendre le matériel, sans quitter le service.</summary>
	[Rpc.Host]
	public static void RequestReturnKit()
	{
		var player = Caller();
		if ( player is null || player.EmsGrade < 0 || !AtLocker( player ) )
			return;
		PoliceService.Current?.ReturnKit( player );
	}

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

	/// <summary>L'accueil de l'hôpital : la liste de ses factures impayées.</summary>
	[Rpc.Host]
	public static void RequestDeskBills()
	{
		var player = Caller();
		if ( player is null || !AtDesk( player ) )
			return;
		_ = PoliceService.Current?.SendEmsDesk( player );
	}

	/// <summary>L'accueil de l'hôpital : payer une facture depuis HZ BANK.</summary>
	[Rpc.Host]
	public static void RequestDeskPay( long billId )
	{
		var player = Caller();
		if ( player is null || billId <= 0 || !AtDesk( player ) )
			return;
		_ = PoliceService.Current?.PayEmsBillAtDesk( player, billId );
	}

	/// <summary>L'accueil de l'hôpital : se faire soigner contre paiement (sans secouriste en service).</summary>
	[Rpc.Host]
	public static void RequestDeskCare()
	{
		var player = Caller();
		if ( player is null || player.OnTheGround || player.Cuffed || !AtDesk( player ) )
			return;
		_ = PoliceService.Current?.DeskCare( player );
	}

	// ------------------------------------------------------------------ points de l'hôpital (staff)

	/// <summary>Les points uniques, et leur nom.</summary>
	public static (string Key, string Label, string Icon)[] Points => new (string, string, string)[]
	{
		(EmsRules.PointLocker, "Vestiaire des secours (service, matériel)", "checkroom"),
		(EmsRules.PointGarage, "Garage des secours (menu des ambulances)", "garage"),
		(EmsRules.PointDesk, "Accueil de l'hôpital (soins, factures)", "local_hospital"),
	};

	/// <summary>
	/// Le staff pose un point de l'hôpital là où il se tient : « poser » pour un point unique ; pour
	/// les sorties du garage, « ajouter » ou « retirer:N ». Comme PoliceActions.RequestStaffPoint.
	/// </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 ( key != EmsRules.PointsGarageSpawn )
			return;
		var points = ServerSettings.EmsGarageSpawns;
		if ( mode == "ajouter" )
		{
			if ( points.Count >= 10 )
			{
				staff.Notify( "Dix sorties de garage 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" ? "Sortie du garage des secours ajoutée ici." : "Sortie du garage des secours retirée." );
	}

	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( "ems_points", staff.SteamId, $"{Who( staff )} : {key} — {done}" );
	}
}