Police/PoliceService.Ems.cs

Partial of PoliceService that handles the EMS/fire service: syncs the medic roster from the backend to all clients as JSON, applies grade/badge/callsign to players, gates the EMS job behind the whitelist, runs the hospital locker duty start/end with kit issue and return, and serves the hospital reception (bill payment and paid healing when no medic is on duty).

Networking
namespace HorizonRP;

/// <summary>
/// Les secours (UCFD EMS, Ems/EmsRules.cs pour les règles), côté hôte, sur le modèle exact de la
/// police (PoliceService.Service.cs). Un partiel de PoliceService plutôt qu'un nouveau composant :
/// celui-ci est déjà dans la scène, et il devient ainsi le système des « services publics ».
///
/// - <b>Synchronisation</b> des effectifs (serverEmsSync) au démarrage, toutes les 30 s et à
///   l'ordre « ems_sync » du panneau, publiés à tous en JSON ; grade, matricule et indicatif posés
///   sur chaque joueur. Un secouriste retiré des effectifs perd le métier et le service.
/// - <b>Liste blanche</b> du métier « EMS » (<see cref="EmsJobRefusal"/>, HorizonGame.TryChangeJob).
/// - <b>Service</b> au vestiaire de l'hôpital (point « ems_vestiaire ») ; paie du grade en service
///   (<see cref="SalaryOf"/>) ; la fin du service reprend le matériel non utilisé et rend
///   l'ambulance (garage commun, PoliceService.Garage.cs).
/// - <b>Accueil de l'hôpital</b> (« ems_accueil ») : payer ses factures, et se faire soigner contre
///   paiement quand aucun secouriste n'est en service.
///
/// Les actions de secours sur un patient (F6) sont dans Ems/EmsActions.cs.
/// </summary>
public sealed partial class PoliceService
{
	/// <summary>Un secouriste inscrit (hz_ems_staff).</summary>
	public sealed record Medic( string SteamId, int Grade, string Badge, string Callsign );

	[Sync( SyncFlags.FromHost )] public string EmsStaffJson { get; set; }

	string parsedEms;
	List<Medic> medics = new();

	public IReadOnlyList<Medic> EmsStaff
	{
		get
		{
			if ( parsedEms != EmsStaffJson )
			{
				parsedEms = EmsStaffJson;
				medics = string.IsNullOrEmpty( EmsStaffJson ) ? new() : Json.Deserialize<List<Medic>>( EmsStaffJson ) ?? new();
			}
			return medics;
		}
	}

	/// <summary>Le secouriste inscrit sous ce SteamID, ou null : la liste blanche du métier.</summary>
	public Medic MedicOf( ulong steamId ) => EmsStaff.FirstOrDefault( x => x.SteamId == steamId.ToString() );

	/// <summary>Partie locale (sans API, donc sans effectifs) : le staff passe pour chef des secours, le temps des essais.</summary>
	public Medic MedicFor( RoleplayPlayer player ) =>
		player is null ? null
		: MedicOf( player.SteamId ) ?? (!Backend.IsOnline && player.IsStaff ? new Medic( player.SteamId.ToString(), EmsRules.MaxRank, "0000", "STAFF" ) : null);

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

	RealTimeSince sinceEmsSync = 1000;
	bool emsSyncing;

	/// <summary>Hôte : relit les effectifs des secours tout de suite (ordre « ems_sync », promotion en jeu).</summary>
	public void RequestEmsSync()
	{
		if ( !Networking.IsHost || emsSyncing )
			return;
		sinceEmsSync = 0;
		_ = EmsSyncAsync();
	}

	async Task EmsSyncAsync()
	{
		if ( !Backend.IsOnline )
			return;
		emsSyncing = true;
		try
		{
			var response = await Backend.EmsSync();
			if ( !this.IsValid() )
				return;
			if ( response?.Ok != true || response.Staff is null )
			{
				if ( response is not null )
					Log.Warning( $"[Horizon] Secours : synchronisation refusée ({response.Error})" );
				return;
			}
			var list = response.Staff
				.Where( x => ulong.TryParse( x.SteamIdText, out _ ) )
				.Select( x => new Medic( x.SteamIdText, Math.Clamp( x.Grade, 0, EmsRules.MaxRank ), x.Badge ?? "", x.Callsign ?? "" ) )
				.ToList();
			var json = Json.Serialize( list );
			if ( json != EmsStaffJson )
				EmsStaffJson = json;
			EmsRosterReceived = true;
			foreach ( var player in RoleplayPlayer.All.ToList() )
				ApplyEmsRosterTo( player );
		}
		finally
		{
			emsSyncing = false;
		}
	}

	/// <summary>Hôte : grade, matricule et indicatif d'après les effectifs ; retiré, il redevient citoyen.</summary>
	public void ApplyEmsRosterTo( RoleplayPlayer player )
	{
		if ( !player.IsValid() || !player.ProfileLoaded )
			return;
		var medic = MedicFor( player );
		var grade = medic?.Grade ?? -1;
		if ( player.EmsGrade != grade )
			player.EmsGrade = grade;
		if ( player.EmsBadge != medic?.Badge )
			player.EmsBadge = medic?.Badge;
		if ( player.EmsCallsign != medic?.Callsign )
			player.EmsCallsign = medic?.Callsign;

		if ( medic is null && player.JobId == EmsRules.JobId && (EmsRosterReceived || !Backend.IsOnline) )
		{
			EndEmsService( player, "retiré des effectifs", notify: false );
			player.JobId = Jobs.DefaultId;
			player.Notify( $"Vous ne figurez plus aux effectifs de l'{EmsRules.Short} {EmsRules.Service} : vous redevenez citoyen.", "annonce" );
			HorizonGame.Current?.AddLog( "ems_effectifs", player.SteamId, $"{player.RpName} n'est plus aux effectifs des secours : métier remis à citoyen" );
		}
	}

	/// <summary>La liste blanche du métier « EMS » : null s'il peut le prendre, sinon la raison du refus.</summary>
	public static string EmsJobRefusal( RoleplayPlayer player )
	{
		if ( player.Jailed )
			return "Pas de métier de secours derrière les barreaux.";
		var service = Current;
		if ( service is null )
			return "Le système des secours n'est pas chargé.";
		if ( Backend.IsOnline && !service.EmsRosterReceived )
			return $"Les effectifs de l'{EmsRules.Short} {EmsRules.Service} ne sont pas encore chargés : réessayez dans quelques secondes.";
		if ( service.MedicFor( player ) is null )
			return $"EMS est un métier sur liste blanche : seuls les secouristes inscrits aux effectifs de l'{EmsRules.Short} le prennent. Adressez-vous au staff.";
		return null;
	}

	/// <summary>« Paramédic Jane Doe (2001) » : la signature des factures et du journal.</summary>
	public static string EmsSignature( RoleplayPlayer medic ) =>
		$"{EmsRules.GradeOf( medic.EmsGrade ).Name} {medic.RpName} ({(string.IsNullOrEmpty( medic.EmsBadge ) ? medic.SteamId.ToString() : medic.EmsBadge)})";

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

	/// <summary>Les stabilisations déjà faites sur un inconscient, pendant cette chute (EmsRules.StabilizeMax).</summary>
	readonly Dictionary<ulong, int> stabilized = new();

	public int StabilizedCount( RoleplayPlayer patient ) => stabilized.GetValueOrDefault( patient.SteamId );

	public void NoteStabilized( RoleplayPlayer patient ) => stabilized[patient.SteamId] = StabilizedCount( patient ) + 1;

	/// <summary>Hôte, chaque seconde (TickSecond) : synchro des effectifs, fin de service qui s'impose.</summary>
	void TickEms()
	{
		if ( sinceEmsSync >= SyncSeconds && !emsSyncing )
		{
			sinceEmsSync = 0;
			_ = EmsSyncAsync();
		}

		foreach ( var player in RoleplayPlayer.All.Where( x => x.IsValid() && x.ProfileLoaded ).ToList() )
		{
			// Plus secouriste (F4, ordre du panneau, renvoi) : fin du service.
			if ( player.EmsOnDuty && (player.JobId != EmsRules.JobId || player.EmsGrade < 0) )
				EndEmsService( player, "changement de métier" );

			// En prison : ni métier de secours, ni service.
			if ( player.Jailed && player.JobId == EmsRules.JobId )
			{
				EndEmsService( player, "en prison", notify: false );
				player.JobId = Jobs.DefaultId;
			}

			if ( !player.Downed )
				stabilized.Remove( player.SteamId );
		}
	}

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

	/// <summary>Hôte : prise de service au vestiaire de l'hôpital.</summary>
	public void StartEmsService( RoleplayPlayer player )
	{
		if ( player.JobId != EmsRules.JobId || player.EmsGrade < 0 )
		{
			player.Notify( $"Le vestiaire est réservé aux secouristes de l'{EmsRules.Short}.", "erreur" );
			return;
		}
		if ( player.EmsOnDuty || player.Jailed || player.Cuffed || player.OnTheGround )
			return;
		if ( !Near( player, ServerSettings.EmsLocker, EmsRules.PointRange * 1.5f ) )
		{
			player.Notify( "La prise de service se fait au vestiaire de l'hôpital.", "erreur" );
			return;
		}

		player.EmsOnDuty = true;
		var grade = EmsRules.GradeOf( player.EmsGrade );
		var callsign = string.IsNullOrEmpty( player.EmsCallsign ) ? "" : $", indicatif {player.EmsCallsign}";
		player.Notify( $"Prise de service : {grade.Name}{callsign}. Matériel au vestiaire, menu des secours ({Keybinds.DisplayOf( "PoliceMenu" )}).", "info" );
		HorizonGame.Current?.AddLog( "ems_service", player.SteamId, $"{EmsSignature( player )} prend son service" );
	}

	/// <summary>
	/// Hôte : fin de service (vestiaire, changement de métier, renvoi, prison, déconnexion). Le
	/// matériel non utilisé repris, l'ambulance rendue, l'escorte lâchée.
	/// </summary>
	public void EndEmsService( RoleplayPlayer player, string reason, bool notify = true )
	{
		if ( !player.IsValid() )
			return;
		var was = player.EmsOnDuty;
		player.EmsOnDuty = false;
		var returned = TakeKit( player );

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

		if ( !was )
			return;

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

	// ------------------------------------------------------------------ matériel

	/// <summary>Hôte : le matériel donné au vestiaire, par secouriste, repris en fin de service s'il n'a pas servi.</summary>
	readonly Dictionary<ulong, Dictionary<string, int>> issuedKit = new();

	/// <summary>Hôte : le matériel du vestiaire (EmsRules.Kit), complété jusqu'à la dotation.</summary>
	public void IssueKit( RoleplayPlayer player )
	{
		if ( !player.IsEms )
			return;
		if ( !Near( player, ServerSettings.EmsLocker, EmsRules.PointRange * 1.5f ) )
		{
			player.Notify( "Le matériel est au vestiaire de l'hôpital.", "erreur" );
			return;
		}
		var mine = issuedKit.GetValueOrDefault( player.SteamId ) ?? new();
		var given = new List<string>();
		foreach ( var kit in EmsRules.Kit.Where( x => player.EmsGrade >= x.MinRank ) )
		{
			if ( Items.Get( kit.ItemId ) is not { } item )
				continue;
			// La dotation complète, pas davantage : ce qui est déjà dans ses poches compte.
			var held = player.Inventory.Where( x => x.Item == kit.ItemId ).Sum( x => x.Count );
			var want = kit.Count - held;
			if ( want <= 0 )
				continue;
			var added = player.AddItem( item, want );
			if ( added <= 0 )
				continue;
			mine[kit.ItemId] = mine.GetValueOrDefault( kit.ItemId ) + added;
			given.Add( $"{added} × {item.Name}" );
		}
		issuedKit[player.SteamId] = mine;
		if ( given.Count == 0 )
		{
			player.Notify( "Votre sac est déjà complet (ou vos poches sont pleines).", "info" );
			return;
		}
		player.Notify( $"Matériel : {string.Join( ", ", given )}.", "info" );
		HorizonGame.Current?.AddLog( "ems_materiel", player.SteamId, $"{EmsSignature( player )} prend {string.Join( ", ", given )}" );
	}

	/// <summary>Hôte : rend le matériel au vestiaire (sans quitter le service).</summary>
	public void ReturnKit( RoleplayPlayer player )
	{
		var count = TakeKit( player );
		player.Notify( count > 0 ? $"Matériel rendu ({count} objet(s))." : "Vous n'avez rien à rendre.", "info" );
		if ( count > 0 )
			HorizonGame.Current?.AddLog( "ems_materiel", player.SteamId, $"{EmsSignature( player )} rend son matériel ({count} objet(s))" );
	}

	/// <summary>Hôte : reprend le matériel donné et pas encore utilisé. Rend le nombre d'objets repris.</summary>
	public int TakeKit( RoleplayPlayer player )
	{
		if ( !issuedKit.Remove( player.SteamId, out var kit ) )
			return 0;
		var taken = 0;
		foreach ( var (itemId, count) in kit )
		{
			var left = count;
			foreach ( var slot in player.Inventory.Where( x => x.Item == itemId ).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;
	}

	/// <summary>Hôte : un objet du matériel consommé par un soin (il ne sera plus repris).</summary>
	public void NoteKitUsed( RoleplayPlayer player, string itemId )
	{
		if ( issuedKit.TryGetValue( player.SteamId, out var kit ) && kit.GetValueOrDefault( itemId ) > 0 )
			kit[itemId]--;
	}

	// ------------------------------------------------------------------ accueil de l'hôpital

	/// <summary>Un secouriste est-il en service (autre que ce joueur) ? L'accueil ne soigne que sans eux.</summary>
	public static bool MedicsOnDuty( RoleplayPlayer except = null ) => RoleplayPlayer.All.Any( x => x.IsEms && x != except );

	/// <summary>Hôte : les factures impayées de ce joueur, envoyées à sa machine (accueil de l'hôpital).</summary>
	public async Task SendEmsDesk( RoleplayPlayer player )
	{
		var price = EmsRules.TariffOf( EmsRules.DeskTariff )?.Price ?? 0;
		if ( !Backend.IsOnline )
		{
			player.EmsDeskBills( "[]", null, price, MedicsOnDuty( player ) );
			return;
		}
		var response = await Backend.EmsBills( player.SteamId );
		if ( !player.IsValid() )
			return;
		if ( response?.Ok != true )
		{
			player.EmsDeskBills( "[]", response?.Error ?? "Le secrétariat ne répond pas : réessayez dans un instant.", price, MedicsOnDuty( player ) );
			return;
		}
		var unpaid = (response.Bills ?? new()).Where( x => x.Unpaid ).ToList();
		player.EmsDeskBills( Json.Serialize( unpaid ), null, price, MedicsOnDuty( player ) );
	}

	/// <summary>Hôte : paie une facture depuis HZ BANK, à l'accueil.</summary>
	public async Task PayEmsBillAtDesk( RoleplayPlayer player, long billId )
	{
		if ( !Backend.IsOnline )
			return;
		var response = await Backend.EmsBillPay( player.SteamId, billId, 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;
		}
		player.Bank = Math.Max( 0, response.Bank );
		HorizonGame.Current?.SaveNow( player );
		player.Notify( $"Facture de soins n°{billId} réglée : -{Format.Money( response.Amount )} (HZ BANK).", "argent" );
		HorizonGame.Current?.AddLog( "ems_facture_payee", player.SteamId, $"{player.RpName} paie la facture de soins n°{billId} à l'accueil de l'hôpital" );
		await SendEmsDesk( player );
		PhoneSystem.Current?.RefreshInvoices( player );
	}

	readonly HashSet<ulong> deskCaring = new();

	/// <summary>
	/// Hôte : l'accueil soigne contre paiement (forfait EmsRules.DeskTariff, débité de HZ BANK par
	/// l'API), seulement si aucun secouriste n'est en service : sinon, ce sont eux qu'on appelle.
	/// </summary>
	public async Task DeskCare( RoleplayPlayer player )
	{
		if ( player.Health >= 100 )
		{
			player.Notify( "L'infirmière vous examine : vous n'avez besoin d'aucun soin.", "info" );
			return;
		}
		if ( MedicsOnDuty( player ) )
		{
			player.Notify( "Des secouristes sont en service : appelez le 911 (secours), ils viennent vous soigner.", "erreur" );
			return;
		}
		if ( !deskCaring.Add( player.SteamId ) )
			return;
		try
		{
			var price = EmsRules.TariffOf( EmsRules.DeskTariff )?.Price ?? 0;
			if ( Backend.IsOnline )
			{
				var response = await Backend.EmsBillCreate( player.SteamId, new List<string> { EmsRules.DeskTariff }, "Accueil de l'hôpital", "Accueil de l'hôpital", 0, payNow: true, balance: player.Bank );
				if ( !player.IsValid() )
					return;
				if ( response?.Ok != true )
				{
					player.Notify( response?.Error ?? "Le secrétariat ne répond pas : réessayez dans un instant.", "erreur" );
					return;
				}
				if ( response.Bank is { } bank )
					player.Bank = Math.Max( 0, bank );
				price = response.Amount;
			}
			player.Notify( "Les infirmières vous prennent en charge…", "info" );
			await GameTask.DelaySeconds( 3f );
			if ( !player.IsValid() )
				return;
			player.Health = 100;
			HorizonGame.Current?.SaveNow( player );
			player.Notify( Backend.IsOnline ? $"Vous êtes soigné. {Format.Money( price )} débités de votre compte HZ BANK." : "Vous êtes soigné (partie locale : rien n'est facturé).", "argent" );
			HorizonGame.Current?.AddLog( "ems_accueil", player.SteamId, $"{player.RpName} soigné à l'accueil de l'hôpital ({Format.Money( price )})" );
		}
		finally
		{
			deskCaring.Remove( player.SteamId );
		}
	}

	// ------------------------------------------------------------------ effectifs en jeu

	/// <summary>
	/// Hôte : un gradé (EmsRules.RankRoster) recrute, promeut, rétrograde ou renvoie le joueur
	/// visé. 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>
	public async Task EmsRosterWrite( RoleplayPlayer chief, RoleplayPlayer target, int? grade )
	{
		var before = MedicOf( target.SteamId );
		var response = await Backend.EmsRoster( target.SteamId, grade, null, EmsSignature( chief ), chief.EmsGrade );
		if ( !chief.IsValid() )
			return;
		if ( response?.Ok != true )
		{
			chief.Notify( response?.Error ?? "Le secrétariat ne répond pas : effectifs inchangés.", "erreur" );
			return;
		}
		string what;
		if ( grade is null )
			what = $"{target.RpName} est renvoyé de l'{EmsRules.Short} {EmsRules.Service}.";
		else if ( before is null )
			what = $"{target.RpName} rejoint l'{EmsRules.Short} {EmsRules.Service} : {EmsRules.GradeOf( grade.Value ).Name}.";
		else if ( grade > before.Grade )
			what = $"{target.RpName} est promu {EmsRules.GradeOf( grade.Value ).Name}.";
		else
			what = $"{target.RpName} est rétrogradé {EmsRules.GradeOf( grade.Value ).Name}.";
		chief.Notify( what, "info" );
		if ( target.IsValid() )
			target.Notify( $"{EmsRules.Short} {EmsRules.Service} : {what}", "annonce" );
		HorizonGame.Current?.AddLog( "ems_effectifs", target.SteamId, $"{what} Par {EmsSignature( chief )}" );
		RequestEmsSync();
	}
}